This repository was archived by the owner on May 7, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 198
feat(wren): CTE-based SQL planning with per-model expansion #1479
Merged
Merged
Changes from 2 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
ef59198
chore: update duckdb arrow method
goldmedal 13b7179
feat(wren): CTE-based SQL planning with per-model expansion
goldmedal 432988c
fix(wren): handle COUNT(*) and other column-less model references in …
goldmedal 77ae5fd
chore: fix fmt
goldmedal 1393bd8
fix(wren): transpile fallback output from Wren dialect to target dialect
goldmedal 7be6adf
fix(wren): use sqlglot instead of DataFusion parser for table name ex…
goldmedal e0387f7
fix(wren): address CodeRabbit review — nested CTE scoping, column ord…
goldmedal 3e132a4
test(wren): add test for nested CTE shadowing model names
goldmedal 364bc73
test(wren): add tests for nested CTE shadowing and WITH RECURSIVE pre…
goldmedal 6cfbb1a
chore: fix fmt
goldmedal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,226 @@ | ||
| """CTE-based SQL rewriter. | ||
|
|
||
| Parses user SQL with sqlglot, uses ``qualify_columns`` to fully resolve | ||
| all column references, then calls wren-core | ||
| ``transform_sql`` once per model with a simple ``SELECT col1, col2 FROM | ||
| model`` and injects each expanded result as a CTE into the original query. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import base64 | ||
| import json | ||
| from collections import defaultdict | ||
|
|
||
| from sqlglot import exp, parse_one | ||
| from sqlglot.optimizer.normalize_identifiers import normalize_identifiers | ||
| from sqlglot.optimizer.qualify_columns import qualify_columns | ||
| from sqlglot.optimizer.qualify_tables import qualify_tables | ||
| from sqlglot.schema import MappingSchema | ||
|
|
||
| # Ensure the Wren dialect is registered with sqlglot on import. | ||
| import wren.mdl.wren_dialect as _wren_dialect # noqa: F401 | ||
| from wren.model.data_source import DataSource | ||
|
|
||
| _SQLGLOT_DIALECT_MAP: dict[DataSource, str] = { | ||
| DataSource.canner: "trino", | ||
| DataSource.mssql: "tsql", | ||
| DataSource.local_file: "duckdb", | ||
| DataSource.s3_file: "duckdb", | ||
| DataSource.minio_file: "duckdb", | ||
| DataSource.gcs_file: "duckdb", | ||
| } | ||
|
|
||
|
|
||
| def get_sqlglot_dialect(data_source: DataSource) -> str: | ||
| """Map a DataSource to a valid sqlglot dialect name.""" | ||
| return _SQLGLOT_DIALECT_MAP.get(data_source, data_source.name) | ||
|
|
||
|
|
||
| class CTERewriter: | ||
| """Rewrite user SQL by expanding MDL model references into CTEs. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| manifest_str: | ||
| Base64-encoded MDL JSON string. | ||
| session_context: | ||
| A ``wren_core.SessionContext`` used to expand per-model SQL. | ||
| data_source: | ||
| The target data source (determines sqlglot dialect). | ||
| fallback: | ||
| When ``True`` (default), if no model references are detected in the | ||
| SQL, fall back to ``session_context.transform_sql()`` directly. | ||
| Set to ``False`` in tests to ensure the CTE path is always exercised | ||
| and silent fallbacks don't mask bugs. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| manifest_str: str, | ||
| session_context, | ||
| data_source: DataSource, | ||
| *, | ||
| fallback: bool = True, | ||
| ): | ||
| self.session_context = session_context | ||
| self.fallback = fallback | ||
| self.dialect = get_sqlglot_dialect(data_source) | ||
| self.manifest = json.loads(base64.b64decode(manifest_str)) | ||
|
|
||
| self.model_dict: dict[str, dict] = {} | ||
| self.schema = MappingSchema(dialect=self.dialect) | ||
| # normalized column name → original manifest column name, per model | ||
| self._col_orig_name: dict[str, dict[str, str]] = {} | ||
|
|
||
| for model in self.manifest.get("models", []): | ||
| name = model["name"] | ||
| self.model_dict[name] = model | ||
| cols: dict[str, str] = {} | ||
| orig: dict[str, str] = {} | ||
| for col in model.get("columns", []): | ||
| if col.get("isHidden"): | ||
| continue | ||
| if col.get("relationship"): | ||
| continue | ||
| col_name = col["name"] | ||
| cols[col_name] = col.get("type", "TEXT") | ||
| orig[col_name.lower()] = col_name | ||
| self.schema.add_table(name, cols, dialect=self.dialect) | ||
| self._col_orig_name[name] = orig | ||
|
|
||
| def rewrite(self, sql: str) -> str: | ||
| """Rewrite *sql* by injecting model CTEs. | ||
|
|
||
| Returns the transformed SQL string in the target sqlglot dialect. | ||
| If no model references are found, falls back to | ||
| ``session_context.transform_sql(sql)`` directly. | ||
| """ | ||
| ast = parse_one(sql, dialect=self.dialect) | ||
|
|
||
| user_cte_names = self._collect_user_cte_names(ast) | ||
| used_columns = self._collect_model_columns(ast, user_cte_names) | ||
|
|
||
| # No model references detected — either fall back to the legacy | ||
| # whole-query transform, or raise so tests can catch the miss. | ||
| if not used_columns: | ||
| if self.fallback: | ||
| return self.session_context.transform_sql(sql) | ||
| raise ValueError(f"No model references found in SQL: {sql}") | ||
|
|
||
| model_ctes = self._build_model_ctes(used_columns) | ||
| self._inject_ctes(ast, model_ctes) | ||
| return ast.sql(dialect=self.dialect) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Column collection via qualify | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def _collect_model_columns( | ||
| self, ast: exp.Expression, user_cte_names: set[str] | ||
| ) -> dict[str, set[str]]: | ||
| """Return ``{model_name: {col1, col2, ...}}`` for all referenced models. | ||
|
|
||
| Uses sqlglot's ``qualify_columns`` to fully resolve all column | ||
| references (including ``SELECT *`` expansion and | ||
| correlated subquery outer references), then walks the qualified AST | ||
| to collect model→column mappings. | ||
| """ | ||
| copy = ast.copy() | ||
| copy = qualify_tables(copy, dialect=self.dialect) | ||
| copy = normalize_identifiers(copy, dialect=self.dialect) | ||
| qualified = qualify_columns( | ||
| copy, | ||
| schema=self.schema, | ||
| dialect=self.dialect, | ||
| allow_partial_qualification=True, | ||
| ) | ||
|
|
||
| # Build alias → model name mapping from Table nodes | ||
| alias_to_model: dict[str, str] = {} | ||
| for table in qualified.find_all(exp.Table): | ||
| table_name = table.name | ||
| if table_name not in self.model_dict or table_name in user_cte_names: | ||
| continue | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| alias = table.alias | ||
| if alias: | ||
| alias_to_model[alias] = table_name | ||
| alias_to_model[table_name] = table_name | ||
|
|
||
| used: dict[str, set[str]] = defaultdict(set) | ||
| for col in qualified.find_all(exp.Column): | ||
| table_ref = col.table | ||
| if not table_ref: | ||
| continue | ||
| model_name = alias_to_model.get(table_ref) | ||
| if model_name: | ||
| used[model_name].add(col.name) | ||
|
|
||
| return dict(used) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # CTE generation | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def _build_model_ctes(self, used_columns: dict[str, set[str]]) -> list[exp.CTE]: | ||
| """Generate one CTE per model via wren-core transform_sql.""" | ||
| ctes: list[exp.CTE] = [] | ||
| for model_name, columns in used_columns.items(): | ||
| orig = self._col_orig_name.get(model_name, {}) | ||
| resolved = [orig.get(c, c) for c in sorted(columns)] | ||
| col_list = ", ".join(f'"{model_name}"."{c}"' for c in resolved) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| model_sql = f'SELECT {col_list} FROM "{model_name}"' | ||
| expanded = self.session_context.transform_sql(model_sql) | ||
|
|
||
| expanded_ast = parse_one(expanded, dialect="wren") | ||
| cte = exp.CTE( | ||
| this=expanded_ast, | ||
| alias=exp.TableAlias(this=exp.to_identifier(model_name, quoted=True)), | ||
| ) | ||
| ctes.append(cte) | ||
| return ctes | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # CTE injection | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def _inject_ctes(self, ast: exp.Expression, model_ctes: list[exp.CTE]) -> None: | ||
| """Prepend *model_ctes* before any existing user CTEs in *ast*.""" | ||
| if not model_ctes: | ||
| return | ||
|
|
||
| existing_with = ast.args.get("with_") | ||
|
|
||
| if existing_with: | ||
| # Prepend model CTEs before user CTEs | ||
| existing_ctes = list(existing_with.expressions) | ||
| all_ctes = model_ctes + existing_ctes | ||
| existing_with.set("expressions", all_ctes) | ||
| else: | ||
| with_clause = exp.With(expressions=model_ctes) | ||
| ast.set("with_", with_clause) | ||
|
|
||
| # Preserve RECURSIVE if the original WITH had it | ||
| final_with = ast.args.get("with_") | ||
| if existing_with and existing_with.args.get("recursive"): | ||
| final_with.set("recursive", True) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Helpers | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| @staticmethod | ||
| def _collect_user_cte_names(ast: exp.Expression) -> set[str]: | ||
| """Collect all CTE names defined in the user's SQL.""" | ||
| names: set[str] = set() | ||
| with_clause = ast.args.get("with_") | ||
| if with_clause: | ||
| for cte in with_clause.expressions: | ||
| alias = cte.args.get("alias") | ||
| if alias: | ||
| names.add( | ||
| alias.this.name | ||
| if isinstance(alias.this, exp.Identifier) | ||
| else str(alias.this) | ||
| ) | ||
| return names | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.