Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/sempy_labs/_generate_semantic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,30 +404,30 @@ def deploy_semantic_model(
)

# Remove mini model annotations from the master model if they exist (cleanup)
#ann_to_remove = [
# ann_to_remove = [
# a.Name
# for a in tom.model.Annotations
# if a.Name.startswith(icons.prefix_mini)
#]
#for ann in ann_to_remove:
# ]
# for ann in ann_to_remove:
# tom.remove_annotation(object=tom.model, name=ann)

# Set annotations to the master model
#if filters is not None or perspective is not None:
# if filters is not None or perspective is not None:
# with connect_semantic_model(
# dataset=source_dataset_id, workspace=source_workspace_id, readonly=False
# ) as tom:

# ann_name = f"{icons.prefix_mini}_{perspective}"

# --- Get existing annotation safely ---
# --- Get existing annotation safely ---
# try:
# ann_value = tom.get_annotation_value(object=tom.model, name=ann_name)
# ann_list = ast.literal_eval(ann_value) if ann_value else []
# except Exception:
# ann_list = []

# --- Build lookup (faster than loop) ---
# --- Build lookup (faster than loop) ---
# index = {a.get("datasetId"): a for a in ann_list}

# if target_dataset_id in index:
Expand Down Expand Up @@ -455,7 +455,7 @@ def deploy_semantic_model(
# }
# )

# --- Save once ---
# --- Save once ---
# tom.set_annotation(object=tom.model, name=ann_name, value=str(ann_list))
Comment on lines 406 to 459
if refresh_target_dataset:
refresh_semantic_model(dataset=target_dataset_id, workspace=target_workspace_id)
Expand Down
11 changes: 11 additions & 0 deletions src/sempy_labs/dax/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from ._parser import (
parse_dax,
)
from ._format import (
format_dax,
)

__all__ = [
"parse_dax",
"format_dax",
]
81 changes: 81 additions & 0 deletions src/sempy_labs/dax/_analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Static analysis helpers over DAX expressions.

These helpers operate purely on the parsed AST and do not require a TOM
connection, which makes them straightforward to unit-test.
"""

from typing import Iterator, Tuple

from ._expressions import Column, Function
from ._parser import parse_dax


# Functions that require their target column(s) to be numeric.
# Value is the argument index that contains the column to aggregate:
# 0 -> the first arg is the column itself (e.g. SUM(<column>))
# 1 -> the second arg is an expression that should be scanned for any
# Column references (e.g. SUMX(<table>, <expression>))
NUMERIC_AGGREGATIONS = {
"SUM": 0,
"AVERAGE": 0,
"MIN": 0,
"MAX": 0,
"PRODUCT": 0,
"SUMX": 1,
"AVERAGEX": 1,
"MINX": 1,
"MAXX": 1,
"PRODUCTX": 1,
}


def find_numeric_aggregation_columns(
expression: str,
) -> Iterator[Tuple[str, str, str]]:
"""
Walk a DAX expression and yield every column referenced by a numeric
aggregation function.

Yields
------
(function_name, table_name, column_name)
One tuple per column reference found inside a numeric-aggregation
function call. The function name is upper-cased.

Notes
-----
Silently yields nothing if the expression cannot be parsed.
"""

if not expression:
return

try:
tree = parse_dax(expression)
except SyntaxError:
return

for fn in tree.find_all(Function):

fn_name = fn.args.get("this", "").upper()

if fn_name not in NUMERIC_AGGREGATIONS:
continue

idx = NUMERIC_AGGREGATIONS[fn_name]
args = fn.args.get("expressions", [])

if idx >= len(args):
continue

target = args[idx]

# For SUM/AVERAGE/etc. the first arg IS the column; for the *X
# variants the column lives somewhere inside the expression arg.
if isinstance(target, Column):
columns = [target]
else:
columns = list(target.find_all(Column))

for col in columns:
yield fn_name, col.args["table"], col.args["this"]
64 changes: 64 additions & 0 deletions src/sempy_labs/dax/_example.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
VAR MaxDate =
MAX ( 'Date'[Date] )

VAR RollingWindow =
DATESINPERIOD (
'Date'[Date],
MaxDate,
-90,
DAY
)

VAR CustomerSalesTable =
ADDCOLUMNS (
SUMMARIZE (
FILTER (
ALL ( Sales ),
Sales[OrderDate] IN RollingWindow
),
Customer[CustomerKey]
),
"RollingSales",
CALCULATE (
SUMX (
Sales,
Sales[ExtendedAmount] - Sales[DiscountAmount]
)
),
"OrderCount",
CALCULATE (
DISTINCTCOUNT ( Sales[OrderNumber] )
)
)

VAR FilteredCustomers =
FILTER (
CustomerSalesTable,
[RollingSales] > 5000
&& [OrderCount] >= 3
)

VAR RankedCustomers =
ADDCOLUMNS (
FilteredCustomers,
"SalesRank",
RANKX (
FilteredCustomers,
[RollingSales],
,
DESC,
DENSE
)
)

VAR Top10Customers =
FILTER (
RankedCustomers,
[SalesRank] <= 10
)

RETURN
SUMX (
Top10Customers,
[RollingSales]
)
126 changes: 126 additions & 0 deletions src/sempy_labs/dax/_expressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from dataclasses import dataclass, field


@dataclass
class Expression:
args: dict = field(default_factory=dict)

def walk(self):
yield self

for value in self.args.values():
if isinstance(value, Expression):
yield from value.walk()

elif isinstance(value, list):
for item in value:
if isinstance(item, Expression):
yield from item.walk()

def find_all(self, expression_type):
for node in self.walk():
if isinstance(node, expression_type):
yield node

def transform(self, func):
new_node = func(self)

for key, value in list(new_node.args.items()):

if isinstance(value, Expression):
new_node.args[key] = value.transform(func)

elif isinstance(value, list):

new_list = []

for item in value:

if isinstance(item, Expression):
item = item.transform(func)

new_list.append(item)

new_node.args[key] = new_list

return new_node

def dump(self, level=0):

indent = " " * level

print(f"{indent}{self.__class__.__name__}")

for key, value in self.args.items():

if isinstance(value, Expression):

print(f"{indent} {key}:")
value.dump(level + 2)

elif isinstance(value, list):

print(f"{indent} {key}:")

for item in value:

if isinstance(item, Expression):
item.dump(level + 2)
else:
print(f"{indent} {item}")

else:
print(f"{indent} {key}: {value}")


class Function(Expression):
pass


class Column(Expression):
pass


class Measure(Expression):
pass


class Table(Expression):
pass


class Literal(Expression):
pass


class Binary(Expression):
pass


class Unary(Expression):
pass


class Var(Expression):
pass


class VariableReference(Expression):
pass


class Keyword(Expression):
"""A bare-identifier DAX enum constant (e.g. DAY, DESC, DENSE, ASC)."""

pass


class VirtualColumn(Expression):
"""A bracketed reference to a column introduced by ADDCOLUMNS /
SELECTCOLUMNS (rather than an existing model measure)."""

pass


class Return(Expression):
pass
Loading
Loading