Skip to content

Commit 3a30dc8

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/DRM/revert-to-shared-connection-for-databricks-access-token-connections
2 parents 542ced4 + 13787d7 commit 3a30dc8

20 files changed

Lines changed: 960 additions & 18 deletions

File tree

docs/concepts/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,4 @@ SQLMesh automatically runs audits when you apply a `plan` to an environment, or
6868
## Infrastructure and orchestration
6969
Every company's data infrastructure is different. SQLMesh is flexible with regard to which engines and orchestration frameworks you use — its only requirement is access to the target SQL/analytics engine.
7070

71-
SQLMesh keeps track of model versions and processed data intervals using your existing infrastructure. SQLMesh it automatically creates a `sqlmesh` schema in your data warehouse for its internal metadata.
71+
SQLMesh keeps track of model versions and processed data intervals using your existing infrastructure. It automatically creates a `sqlmesh` schema in your data warehouse for its internal metadata.

docs/guides/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ The examples specify a Snowflake connection whose password is stored in an envir
170170
account: <account>
171171
```
172172

173+
!!! tip "Base64-encoded secrets"
174+
175+
If a secret is distributed base64-encoded in a single environment variable (for example a BigQuery service-account key), pipe the variable through the built-in `b64decode` filter to decode it to text inline:
176+
177+
```yaml
178+
keyfile_json: {{ env_var('BIGQUERY_KEY_B64') | b64decode }}
179+
```
180+
181+
A matching `b64encode` filter is also available. Both return UTF-8 text, so they are intended for string/JSON secrets rather than arbitrary binary data.
182+
173183
=== "Python"
174184

175185
Python accesses environment variables via the `os` library's `environ` dictionary.

docs/integrations/engines/databricks.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,25 @@ MODEL (
310310

311311
If you attempt to alter without having this property set, you will get an error similar to `databricks.sql.exc.ServerOperationError: [DELTA_UNSUPPORTED_DROP_COLUMN] DROP COLUMN is not supported for your Delta table.`.
312312
[Databricks Documentation for more details](https://docs.databricks.com/en/delta/column-mapping.html#requirements).
313+
314+
## Liquid Clustering
315+
316+
SQLMesh supports the liquid clustering keywords AUTO and NONE
317+
318+
```sql
319+
MODEL (
320+
name sqlmesh_example.new_model,
321+
...
322+
clustered_by AUTO
323+
)
324+
```
325+
326+
To cluster by a column called `auto` or `none`, use parentheses and backticks
327+
328+
```sql
329+
MODEL (
330+
name sqlmesh_example.new_model,
331+
...
332+
clustered_by (`auto`)
333+
)
334+
```

sqlmesh/core/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,5 @@
9696
HYBRID = "hybrid"
9797

9898
DISABLE_SQLMESH_STATE_MIGRATION = "SQLMESH__AIRFLOW__DISABLE_STATE_MIGRATION"
99+
100+
LIQUID_CLUSTERING_KEYWORDS: frozenset = frozenset({"AUTO", "NONE"})

sqlmesh/core/dialect.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from sqlglot.schema import MappingSchema
2525
from sqlglot.tokens import Token
2626

27-
from sqlmesh.core.constants import MAX_MODEL_DEFINITION_SIZE
27+
from sqlmesh.core.constants import LIQUID_CLUSTERING_KEYWORDS, MAX_MODEL_DEFINITION_SIZE
2828
from sqlmesh.utils import get_source_columns_to_types
2929
from sqlmesh.utils.errors import SQLMeshError, ConfigError
3030
from sqlmesh.utils.pandas import columns_to_types_from_df
@@ -663,6 +663,27 @@ def parse(self: Parser) -> t.Optional[exp.Expr]:
663663
value = exp.tuple_(*partitioned_by.this.expressions)
664664
else:
665665
value = partitioned_by.this
666+
elif key == "clustered_by":
667+
# Bare AUTO / NONE are Databricks liquid clustering keywords, not column refs.
668+
# Detect keywords by token type: unquoted bare identifiers arrive as VAR tokens.
669+
# Backtick-quoted identifiers (e.g. `auto`) have IDENTIFIER token type and are
670+
# treated as real column names.
671+
if (
672+
self._curr is not None
673+
and self._curr.token_type == TokenType.VAR
674+
and self._curr.text.upper() in LIQUID_CLUSTERING_KEYWORDS
675+
):
676+
value = exp.Var(this=self._curr.text.upper())
677+
self._advance()
678+
else:
679+
parsed = self._parse_bracket(self._parse_field(any_token=True))
680+
# Unwrap Paren wrapping a bare column to match partitioned_by normalisation:
681+
# clustered_by (a) → stored as Column(a), not Paren(Column(a)).
682+
# Preserve parens around function expressions: (TO_DATE(col)) stays as-is.
683+
if isinstance(parsed, exp.Paren) and isinstance(parsed.this, exp.Column):
684+
value = parsed.unnest()
685+
else:
686+
value = parsed
666687
else:
667688
value = self._parse_bracket(self._parse_field(any_token=True))
668689

@@ -783,7 +804,10 @@ def format_model_expressions(
783804
A string representing the formatted model.
784805
"""
785806
if len(expressions) == 1 and is_meta_expression(expressions[0]):
786-
return expressions[0].sql(pretty=True, dialect=dialect)
807+
# Meta expressions (MODEL/AUDIT/METRIC) are SQLMesh DDL, not standard SQL,
808+
# so they must never be transpiled to the target dialect (e.g. tsql would
809+
# rewrite a boolean property like `allow_partials TRUE` to `(1 = 1)`).
810+
return expressions[0].sql(pretty=True, dialect=None)
787811

788812
if rewrite_casts:
789813

@@ -815,7 +839,14 @@ def cast_to_colon(node: exp.Expr) -> exp.Expr:
815839
expressions = new_expressions
816840

817841
return ";\n\n".join(
818-
expression.sql(pretty=True, dialect=dialect, **kwargs) for expression in expressions
842+
# Meta expressions (MODEL/AUDIT/METRIC) are SQLMesh DDL and must stay
843+
# dialect-agnostic; only the actual query/statement expressions transpile.
844+
expression.sql(
845+
pretty=True,
846+
dialect=None if is_meta_expression(expression) else dialect,
847+
**kwargs,
848+
)
849+
for expression in expressions
819850
).strip()
820851

821852

sqlmesh/core/engine_adapter/databricks.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from sqlglot import exp
88

9+
from sqlmesh.core.constants import LIQUID_CLUSTERING_KEYWORDS
910
from sqlmesh.core.dialect import to_schema
1011
from sqlmesh.core.engine_adapter.mixins import GrantsFromInfoSchemaMixin
1112
from sqlmesh.core.engine_adapter.shared import (
@@ -442,10 +443,16 @@ def _build_table_properties_exp(
442443
table_kind=table_kind,
443444
)
444445
if clustered_by:
445-
# Databricks expects wrapped CLUSTER BY expressions
446-
clustered_by_exp = exp.Cluster(
447-
expressions=[exp.Tuple(expressions=[c.copy() for c in clustered_by])]
448-
)
446+
if len(clustered_by) == 1 and isinstance(clustered_by[0], exp.Var):
447+
if clustered_by[0].name.upper() not in LIQUID_CLUSTERING_KEYWORDS:
448+
raise ValueError(f"Unexpected bare Var in clustered_by: {clustered_by[0]!r}")
449+
# exp.Cluster with a bare Var generates: CLUSTER BY AUTO (no parens)
450+
clustered_by_exp = exp.Cluster(expressions=[clustered_by[0].copy()])
451+
else:
452+
# Databricks expects column expressions wrapped in a tuple
453+
clustered_by_exp = exp.Cluster(
454+
expressions=[exp.Tuple(expressions=[c.copy() for c in clustered_by])]
455+
)
449456
expressions = properties.expressions if properties else []
450457
expressions.append(clustered_by_exp)
451458
properties = exp.Properties(expressions=expressions)

sqlmesh/core/engine_adapter/fabric.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_result
1010
from sqlmesh.core.engine_adapter.mssql import MSSQLEngineAdapter
1111
from sqlmesh.core.engine_adapter.shared import (
12+
CommentCreationTable,
13+
CommentCreationView,
1214
InsertOverwriteStrategy,
1315
)
1416
from sqlmesh.utils.errors import SQLMeshError
@@ -30,6 +32,10 @@ class FabricEngineAdapter(MSSQLEngineAdapter):
3032
SUPPORTS_TRANSACTIONS = False
3133
SUPPORTS_CREATE_DROP_CATALOG = True
3234
INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.DELETE_INSERT
35+
# There is no standard method to handle comments in Fabric for now, so we disable it.
36+
# Otherwise, it would be inherited from MSSQL and would not work.
37+
COMMENT_CREATION_TABLE = CommentCreationTable.UNSUPPORTED
38+
COMMENT_CREATION_VIEW = CommentCreationView.UNSUPPORTED
3339

3440
def __init__(
3541
self, connection_factory_or_pool: t.Union[t.Callable, t.Any], *args: t.Any, **kwargs: t.Any

sqlmesh/core/model/definition.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,12 @@ def validate_definition(self) -> None:
992992
values = [
993993
col.name
994994
for expr in values
995+
if not (
996+
field == "clustered_by"
997+
and (self.dialect or "").lower() == "databricks"
998+
and isinstance(expr, exp.Var)
999+
and expr.name.upper() in c.LIQUID_CLUSTERING_KEYWORDS
1000+
)
9951001
for col in t.cast(
9961002
exp.Expr, exp.maybe_parse(expr, dialect=self.dialect)
9971003
).find_all(exp.Column)
@@ -1588,7 +1594,7 @@ def is_breaking_change(self, previous: Model) -> t.Optional[bool]:
15881594

15891595
for edit in edits:
15901596
if not isinstance(edit, Insert):
1591-
return None
1597+
return _additive_projection_change(previous_query, this_query, self.dialect)
15921598

15931599
expr = edit.expression
15941600
if isinstance(expr, exp.UDTF):
@@ -1602,7 +1608,7 @@ def is_breaking_change(self, previous: Model) -> t.Optional[bool]:
16021608
expr = parent
16031609

16041610
if not _is_projection(expr) and expr.parent not in inserted_expressions:
1605-
return None
1611+
return _additive_projection_change(previous_query, this_query, self.dialect)
16061612

16071613
return False
16081614

@@ -2907,6 +2913,98 @@ def _is_projection(expr: exp.Expr) -> bool:
29072913
return isinstance(parent, exp.Select) and expr.arg_key == "expressions"
29082914

29092915

2916+
def _has_ordinal_references(query: exp.Select) -> bool:
2917+
order = query.args.get("order")
2918+
if order and any(
2919+
isinstance(ob.this, exp.Literal) and ob.this.is_number for ob in order.expressions
2920+
):
2921+
return True
2922+
group = query.args.get("group")
2923+
return bool(
2924+
group and any(isinstance(gb, exp.Literal) and gb.is_number for gb in group.expressions)
2925+
)
2926+
2927+
2928+
def _additive_projection_change(
2929+
previous_query: exp.Query,
2930+
this_query: exp.Query,
2931+
dialect: DialectType,
2932+
) -> t.Optional[bool]:
2933+
"""Fallback for when SQLGlot's tree diff can't express an additive projection change.
2934+
2935+
SQLGlot's diff matches nodes by structural similarity, so interchangeable leaves (e.g. two
2936+
identical ``CAST(... AS T)`` target types) can be cross-matched. Inserting a same-type cast
2937+
above an existing one therefore yields spurious ``Move`` / ``Update`` edits even though a
2938+
column was simply added to the SELECT list. In that case the edit-based check above is
2939+
inconclusive, so we verify additivity directly against the output projections.
2940+
2941+
Returns ``False`` (non-breaking) only when the change is provably additive:
2942+
* both queries are simple ``SELECT`` statements,
2943+
* everything other than the projection list is structurally identical,
2944+
* no added projection is a (potentially cardinality-changing) ``UDTF``,
2945+
* every previous projection is preserved, in order, within the new projection list, and
2946+
* no mid-list insert shifts ordinal ``ORDER BY`` / ``GROUP BY`` references.
2947+
2948+
Otherwise returns ``None`` (undetermined), preserving the conservative default.
2949+
"""
2950+
# UNIONs or other query expressions, are left to the caller's conservative diff result.
2951+
if not isinstance(previous_query, exp.Select) or not isinstance(this_query, exp.Select):
2952+
return None
2953+
2954+
previous_projections = previous_query.expressions
2955+
this_projections = this_query.expressions
2956+
# If the new query has not gained any projections, this cannot be an additive projection-only
2957+
# change, so there is nothing for this fallback to prove.
2958+
if len(this_projections) <= len(previous_projections):
2959+
return None
2960+
2961+
# Adding a UDTF projection (e.g. EXPLODE / UNNEST) can change row cardinality, so such a
2962+
# change is not safely non-breaking even when it appears as an extra SELECT item.
2963+
for projection in this_projections:
2964+
bare = projection.this if isinstance(projection, exp.Alias) else projection
2965+
if isinstance(bare, exp.UDTF):
2966+
return None
2967+
2968+
# Everything other than the projection list must be structurally identical. Replacing each
2969+
# SELECT list with the same dummy literal lets the expression equality check focus on the
2970+
# FROM / WHERE / GROUP BY / ORDER BY / etc. parts of the query.
2971+
previous_skeleton = previous_query.copy()
2972+
this_skeleton = this_query.copy()
2973+
previous_skeleton.set("expressions", [exp.Literal.number(1)])
2974+
this_skeleton.set("expressions", [exp.Literal.number(1)])
2975+
if previous_skeleton != this_skeleton:
2976+
return None
2977+
2978+
# Every previous projection must appear, in order, within the new projection list. Comparing
2979+
# dialect-normalized SQL makes semantically equivalent projection nodes match even when the
2980+
# parser built distinct object identities.
2981+
this_projection_sql = [p.sql(dialect=dialect, comments=False) for p in this_projections]
2982+
search_start = 0
2983+
matched_at: list[int] = []
2984+
for projection in previous_projections:
2985+
target_sql = projection.sql(dialect=dialect, comments=False)
2986+
# Continue after the previous match so added columns can appear before, between, or after
2987+
# the original projections, but existing projections cannot be reordered or rewritten.
2988+
for index in range(search_start, len(this_projection_sql)):
2989+
if this_projection_sql[index] == target_sql:
2990+
matched_at.append(index)
2991+
search_start = index + 1
2992+
break
2993+
else:
2994+
return None
2995+
2996+
# Mid-list inserts shift ordinal references in ORDER BY / GROUP BY clauses.
2997+
if _has_ordinal_references(this_query):
2998+
matched_set = set(matched_at)
2999+
last_matched = matched_at[-1]
3000+
if any(i < last_matched for i in range(len(this_projections)) if i not in matched_set):
3001+
return None
3002+
3003+
# At this point the query shape is unchanged and all prior outputs are preserved, so the only
3004+
# remaining difference is one or more additional, non-UDTF projections.
3005+
return False
3006+
3007+
29103008
def _single_expr_or_tuple(values: t.Sequence[exp.Expr]) -> exp.Expr | exp.Tuple:
29113009
return values[0] if len(values) == 1 else exp.Tuple(expressions=values)
29123010

sqlmesh/core/model/meta.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from sqlmesh.core import dialect as d
1414
from sqlmesh.core.config.common import VirtualEnvironmentMode
15+
from sqlmesh.core.constants import LIQUID_CLUSTERING_KEYWORDS
1516
from sqlmesh.core.config.linter import LinterConfig
1617
from sqlmesh.core.dialect import normalize_model_name
1718
from sqlmesh.utils import classproperty
@@ -190,10 +191,13 @@ def _gateway_validator(cls, v: t.Any) -> t.Optional[str]:
190191

191192
@field_validator("partitioned_by_", "clustered_by", mode="before")
192193
def _partition_and_cluster_validator(cls, v: t.Any, info: ValidationInfo) -> t.List[exp.Expr]:
194+
field = info.field_name or ""
195+
dialect = (get_dialect(info) or "").lower()
196+
193197
if (
194198
isinstance(v, list)
195199
and all(isinstance(i, str) for i in v)
196-
and (info.field_name or "") == "partitioned_by_"
200+
and field == "partitioned_by_"
197201
):
198202
# this branch gets hit when we are deserializing from json because `partitioned_by` is stored as a List[str]
199203
# however, we should only invoke this if the list contains strings because this validator is also
@@ -206,9 +210,33 @@ def _partition_and_cluster_validator(cls, v: t.Any, info: ValidationInfo) -> t.L
206210
)
207211
v = parsed.this.expressions if isinstance(parsed.this, exp.Schema) else v
208212

213+
if isinstance(v, str) and field == "clustered_by":
214+
v = [v]
215+
216+
if isinstance(v, list) and field == "clustered_by" and dialect == "databricks":
217+
# When deserializing from JSON, clustered_by is stored as List[str].
218+
# Restore keyword sentinels (AUTO/NONE) before list_of_fields_validator normalises
219+
# them into quoted columns.
220+
v = [
221+
exp.Var(this=item.upper())
222+
if isinstance(item, str) and item.upper() in LIQUID_CLUSTERING_KEYWORDS
223+
else item
224+
for item in v
225+
]
226+
209227
expressions = list_of_fields_validator(v, validation_data(info))
210228

211229
for expression in expressions:
230+
# AUTO and NONE are Databricks liquid clustering keywords, not column references.
231+
# Only skip for clustered_by with the Databricks dialect — meaningless elsewhere.
232+
if (
233+
field == "clustered_by"
234+
and dialect == "databricks"
235+
and isinstance(expression, exp.Var)
236+
and expression.name.upper() in LIQUID_CLUSTERING_KEYWORDS
237+
):
238+
continue
239+
212240
num_cols = len(list(expression.find_all(exp.Column)))
213241

214242
error_msg: t.Optional[str] = None

sqlmesh/integrations/dlt.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def generate_incremental_model(
208208
FROM
209209
{from_clause}
210210
WHERE
211-
{time_column} BETWEEN @start_ds AND @end_ds
211+
{time_column} BETWEEN @start_ts AND @end_ts
212212
"""
213213

214214

0 commit comments

Comments
 (0)