-
Notifications
You must be signed in to change notification settings - Fork 12
Fix pre-aggregation materialization correctness #302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -229,20 +229,36 @@ def generate_materialization_sql(self, model: Any, partition_filter: str | None | |
| # PERCENTILE) are not re-aggregatable. Skip them (the matcher likewise | ||
| # never routes complete-expression measures to a rollup). | ||
| continue | ||
| # Generate aggregation expression | ||
| # Generate aggregation expression. Metric filters belong inside | ||
| # the aggregate input, exactly as they do for live queries; a | ||
| # rollup must never materialize an unfiltered value for a | ||
| # filtered semantic metric. | ||
| agg_type = measure.agg.upper() | ||
| if agg_type == "COUNT" and not measure.sql: | ||
| filter_sql = " AND ".join( | ||
| condition.replace("{model}.", "").replace("{model}", "") | ||
| for condition in (measure.filters or []) | ||
| ) | ||
| measure_input = measure.sql_expr | ||
| if filter_sql: | ||
| if agg_type == "COUNT" and not measure.sql: | ||
| measure_input = f"CASE WHEN {filter_sql} THEN 1 ELSE NULL END" | ||
| else: | ||
| measure_input = f"CASE WHEN {filter_sql} THEN {measure_input} ELSE NULL END" | ||
|
|
||
| if agg_type == "COUNT" and not measure.sql and not filter_sql: | ||
| # COUNT(*) case | ||
| select_exprs.append(f"COUNT(*) as {measure_name}_raw") | ||
| elif agg_type == "COUNT" and not measure.sql: | ||
| select_exprs.append(f"COUNT({measure_input}) as {measure_name}_raw") | ||
| elif agg_type == "AVG": | ||
| # Store AVG as additive sum state. A compatible count | ||
| # measure must also be present before query planning can | ||
| # roll this up safely. | ||
| select_exprs.append(f"SUM({measure.sql_expr}) as {measure_name}_raw") | ||
| select_exprs.append(f"SUM({measure_input}) as {measure_name}_raw") | ||
| elif agg_type == "COUNT_DISTINCT": | ||
| select_exprs.append(f"COUNT(DISTINCT {measure.sql_expr}) as {measure_name}_raw") | ||
| select_exprs.append(f"COUNT(DISTINCT {measure_input}) as {measure_name}_raw") | ||
| else: | ||
| select_exprs.append(f"{agg_type}({measure.sql_expr}) as {measure_name}_raw") | ||
| select_exprs.append(f"{agg_type}({measure_input}) as {measure_name}_raw") | ||
|
|
||
| # A rollup that projects nothing would render "SELECT FROM ... GROUP BY " (invalid | ||
| # SQL). This happens when its measures are all non-materializable (agg=None: derived or | ||
|
|
@@ -270,8 +286,9 @@ def generate_materialization_sql(self, model: Any, partition_filter: str | None | |
|
|
||
| sql = f"""SELECT | ||
| {select_str} | ||
| FROM {from_clause}{where_clause} | ||
| GROUP BY {group_by_str}""" | ||
| FROM {from_clause}{where_clause}""" | ||
| if group_by_str: | ||
| sql += f"\nGROUP BY {group_by_str}" | ||
|
|
||
| return sql | ||
|
|
||
|
|
@@ -754,6 +771,11 @@ def _refresh_incremental( | |
| if not table_exists: | ||
| connection.execute(f"CREATE TABLE {table_name} AS {incremental_sql}") | ||
| else: | ||
| # A lookback reprocesses an overlapping watermark range. Delete that | ||
| # range before inserting its replacement so repeated refreshes remain | ||
| # idempotent instead of accumulating duplicate rollup rows. | ||
| if lookback: | ||
| connection.execute(f"DELETE FROM {table_name} WHERE {watermark_column} >= {watermark_str}") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a lookback refresh uses the documented/common Useful? React with 👍 / 👎. |
||
| connection.execute(f"INSERT INTO {table_name} {incremental_sql}") | ||
|
Comment on lines
+778
to
779
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On autocommit connections such as a direct DuckDB connection, any schema, type, or execution error in the insert after this delete leaves the pre-aggregation permanently missing its entire lookback window. Run the delete-and-insert replacement in one transaction or stage the replacement before deleting existing rows. Useful? React with 👍 / 👎. |
||
|
|
||
| # Get new watermark | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sql="*"as a filtered row countWhen a filtered row-count metric is represented as
Metric(agg="count", sql="*", filters=[...]),measure.sqlis truthy and this branch producesCOUNT(CASE WHEN ... THEN * ELSE NULL END), which DuckDB and other engines reject because*cannot appear inside aCASE. Handlesql == "*"like the existing no-SQL row-count case and place1in the filtered aggregate input.Useful? React with 👍 / 👎.