Skip to content

Commit 246a203

Browse files
authored
Merge branch 'main' into fix/redshift-single-column-sortkey
2 parents 1952387 + e91c9b9 commit 246a203

12 files changed

Lines changed: 414 additions & 18 deletions

File tree

docs/guides/linter.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ Error: Linter detected errors in the code. Please fix them before proceeding.
126126

127127
Use `sqlmesh lint --help` for more information.
128128

129+
You can pass `--local` to run lint without loading state from the configured state connection:
130+
131+
``` bash
132+
$ sqlmesh lint --local
133+
```
134+
135+
This can make linting faster in repositories where all referenced models are loaded from local files. In multi-repository setups, or when linting only a subset of projects, `--local` may cause additional linting errors because SQLMesh will not resolve references or schemas from models that exist only in remote state.
136+
129137

130138
## Applying linting rules
131139

@@ -258,4 +266,4 @@ You may specify that a rule's violation should not error and only log a warning
258266
)
259267
```
260268

261-
SQLMesh will raise an error if the same rule is included in more than one of the `rules`, `warn_rules`, and `ignored_rules` keys since they should be mutually exclusive.
269+
SQLMesh will raise an error if the same rule is included in more than one of the `rules`, `warn_rules`, and `ignored_rules` keys since they should be mutually exclusive.

docs/reference/cli.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,9 @@ Usage: sqlmesh lint [OPTIONS]
650650
651651
Options:
652652
--model TEXT A model to lint. Multiple models can be linted. If no models are specified, every model will be linted.
653+
--local Lint using only locally loaded project files without loading state. In multi-repository setups, or when
654+
linting only a subset of projects, this may cause additional linting errors because SQLMesh will not resolve
655+
references or schemas from models that exist only in remote state.
653656
--help Show this message and exit.
654657
655-
```
658+
```

docs/reference/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Formatting settings for the `sqlmesh format` command and UI.
114114
| `normalize` | Whether to normalize SQL (Default: False) | boolean | N |
115115
| `pad` | The number of spaces to use for padding (Default: 2) | int | N |
116116
| `indent` | The number of spaces to use for indentation (Default: 2) | int | N |
117-
| `normalize_functions` | Whether to normalize function names. Supported values are: 'upper' and 'lower' (Default: None) | string | N |
117+
| `normalize_functions` | How to normalize function name casing. `false` (default) preserves the casing of custom and audit function names as written; `"upper"` uppercases all function names; `"lower"` lowercases all function names; `true` defers to SQLGlot's generator default and uppercases all function names including custom ones; `null` (or omitting the key) is excluded during serialization and therefore takes the same `false` default path — it does **not** defer to SQLGlot's generator default. Note: SQLGlot built-in function names may still be canonicalized by the parser regardless of this setting. | string \| boolean \| null | N |
118118
| `leading_comma` | Whether to use leading commas (Default: False) | boolean | N |
119119
| `max_text_width` | The maximum text width in a segment before creating new lines (Default: 80) | int | N |
120120
| `append_newline` | Whether to append a newline to the end of the file (Default: False) | boolean | N |

sqlmesh/cli/main.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@
4444
LOCAL_ONLY_COMMANDS = ("format",)
4545

4646

47+
class _SQLMeshGroup(click.Group):
48+
def parse_args(self, ctx: click.Context, args: t.List[str]) -> t.List[str]:
49+
rest = super().parse_args(ctx, args)
50+
# Preserve the subcommand arguments because Click consumes them before invoking the group callback.
51+
protected_args = getattr(ctx, "_protected_args", None)
52+
if protected_args is None:
53+
protected_args = ctx.protected_args
54+
ctx.meta["subcommand_args"] = tuple(protected_args) + tuple(ctx.args)
55+
return rest
56+
57+
4758
def _sqlmesh_version() -> str:
4859
try:
4960
from sqlmesh import __version__
@@ -53,7 +64,7 @@ def _sqlmesh_version() -> str:
5364
return "0.0.0"
5465

5566

56-
@click.group(no_args_is_help=True)
67+
@click.group(cls=_SQLMeshGroup, no_args_is_help=True)
5768
@click.version_option(version=_sqlmesh_version(), message="%(version)s")
5869
@opt.paths
5970
@opt.config
@@ -118,6 +129,9 @@ def cli(
118129
load = True
119130
# Local-only gating must hold for any number of --paths, so it stays outside the block below.
120131
load_state = ctx.invoked_subcommand not in LOCAL_ONLY_COMMANDS
132+
# The parent callback constructs Context before Click invokes `lint`, so inspect its parsed args here.
133+
if ctx.invoked_subcommand == "lint" and "--local" in ctx.meta["subcommand_args"]:
134+
load_state = False
121135

122136
if len(paths) == 1:
123137
path = os.path.abspath(paths[0])
@@ -1194,6 +1208,12 @@ def environments(obj: Context) -> None:
11941208
multiple=True,
11951209
help="A model to lint. Multiple models can be linted. If no models are specified, every model will be linted.",
11961210
)
1211+
@click.option(
1212+
"--local",
1213+
is_flag=True,
1214+
expose_value=False,
1215+
help="Lint using only locally loaded project files without loading state.",
1216+
)
11971217
@click.pass_obj
11981218
@error_handler
11991219
@cli_analytics

sqlmesh/core/config/format.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@ class FormatConfig(BaseConfig):
1212
normalize: Whether to normalize the SQL code or not.
1313
pad: The number of spaces to use for padding.
1414
indent: The number of spaces to use for indentation.
15-
normalize_functions: Whether or not to normalize all function names. Possible values are: 'upper', 'lower'
15+
normalize_functions: How to normalize function name casing.
16+
17+
* ``False`` (default) — preserves the original spelling of custom and audit
18+
function names. SQLGlot built-in functions (e.g. ``COUNT``, ``SUM``) may
19+
still be uppercased because the parser discards the original token.
20+
* ``"upper"`` — uppercases all function names, including custom audit
21+
references.
22+
* ``"lower"`` — lowercases all function names, including built-in ones.
23+
* ``True`` — defers to SQLGlot's generator default, which uppercases all
24+
function names including custom ones.
25+
* ``None`` — excluded from the serialized generator options by Pydantic's
26+
``exclude_none`` behaviour, so ``format_model_expressions`` falls back to
27+
its own ``False`` default. Setting this in YAML as ``null`` or omitting
28+
the key is therefore equivalent to ``false``; it does **not** defer to
29+
SQLGlot's generator default the way ``True`` does.
1630
leading_comma: Whether to use leading commas or not.
1731
max_text_width: The maximum text width in a segment before creating new lines.
1832
append_newline: Whether to append a newline to the end of the file or not.
@@ -22,7 +36,7 @@ class FormatConfig(BaseConfig):
2236
normalize: bool = False
2337
pad: int = 2
2438
indent: int = 2
25-
normalize_functions: t.Optional[str] = None
39+
normalize_functions: t.Union[str, bool, None] = False
2640
leading_comma: bool = False
2741
max_text_width: int = 80
2842
append_newline: bool = False

sqlmesh/core/config/root.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,24 @@ def validate_regex_key_dict(value: t.Dict[str | re.Pattern, t.Any]) -> t.Dict[re
7676
return compile_regex_mapping(value)
7777

7878

79+
def _canonicalize(obj: object) -> object:
80+
"""Recursively convert an object into a canonical, order-stable form for hashing.
81+
82+
``set``/``frozenset`` iteration order is not stable across Python processes, so
83+
pickling them directly yields non-deterministic bytes. That makes any hash derived
84+
from the pickle (e.g. ``Config.fingerprint``) change run-to-run, which silently
85+
invalidates on-disk caches keyed by the fingerprint. Sorting set members into a
86+
list restores determinism while preserving contents.
87+
"""
88+
if isinstance(obj, (set, frozenset)):
89+
return sorted(map(_canonicalize, obj)) # type: ignore[type-var]
90+
if isinstance(obj, dict):
91+
return {k: _canonicalize(v) for k, v in obj.items()}
92+
if isinstance(obj, (list, tuple)):
93+
return type(obj)(map(_canonicalize, obj))
94+
return obj
95+
96+
7997
if t.TYPE_CHECKING:
8098
from sqlmesh.core._typing import Self
8199

@@ -364,4 +382,8 @@ def dialect(self) -> t.Optional[str]:
364382

365383
@property
366384
def fingerprint(self) -> str:
367-
return str(zlib.crc32(pickle.dumps(self.dict(exclude={"loader", "notification_targets"}))))
385+
return str(
386+
zlib.crc32(
387+
pickle.dumps(_canonicalize(self.dict(exclude={"loader", "notification_targets"})))
388+
)
389+
)

sqlmesh/core/dialect.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ def format_model_expressions(
790790
expressions: t.List[exp.Expr],
791791
dialect: t.Optional[str] = None,
792792
rewrite_casts: bool = True,
793+
normalize_functions: t.Union[str, bool, None] = False,
793794
**kwargs: t.Any,
794795
) -> str:
795796
"""Format a model's expressions into a standardized format.
@@ -798,6 +799,21 @@ def format_model_expressions(
798799
expressions: The model's expressions, must be at least model def + query.
799800
dialect: The dialect to render the expressions as.
800801
rewrite_casts: Whether to rewrite all casts to use the :: syntax.
802+
normalize_functions: How to normalize function name casing.
803+
804+
* ``False`` (default) — preserves the original spelling of custom and audit
805+
function names. SQLGlot built-in functions may still canonicalize because
806+
the parser discards the original token.
807+
* ``"upper"`` — uppercases all function names including custom audit
808+
references.
809+
* ``"lower"`` — lowercases all function names including built-ins.
810+
* ``True`` — defers to SQLGlot's generator default (uppercase).
811+
* ``None`` — passes ``None`` directly to the SQLGlot generator, which
812+
defers to SQLGlot's own default (typically uppercase, but may vary by
813+
dialect). Note: this is the **direct generator API** behaviour. When
814+
called via ``FormatConfig``, ``None`` is excluded by Pydantic's
815+
``exclude_none`` serialization and this function receives its own ``False``
816+
default instead — so the two paths are not equivalent.
801817
**kwargs: Additional keyword arguments to pass to the sql generator.
802818
803819
Returns:
@@ -807,7 +823,9 @@ def format_model_expressions(
807823
# Meta expressions (MODEL/AUDIT/METRIC) are SQLMesh DDL, not standard SQL,
808824
# so they must never be transpiled to the target dialect (e.g. tsql would
809825
# rewrite a boolean property like `allow_partials TRUE` to `(1 = 1)`).
810-
return expressions[0].sql(pretty=True, dialect=None)
826+
return expressions[0].sql(
827+
pretty=True, dialect=None, normalize_functions=normalize_functions
828+
)
811829

812830
if rewrite_casts:
813831

@@ -844,6 +862,7 @@ def cast_to_colon(node: exp.Expr) -> exp.Expr:
844862
expression.sql(
845863
pretty=True,
846864
dialect=None if is_meta_expression(expression) else dialect,
865+
normalize_functions=normalize_functions,
847866
**kwargs,
848867
)
849868
for expression in expressions

sqlmesh/core/macros.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,7 @@ def filter_(evaluator: MacroEvaluator, *args: t.Any) -> t.List[t.Any]:
789789
"""
790790
*items, func = args
791791
items, func = _norm_var_arg_lambda(evaluator, func, *items) # type: ignore
792-
return list(filter(lambda arg: evaluator.eval_expression(func(arg)), items))
792+
return list(filter(lambda arg: evaluator.eval_expression(func(arg)), ensure_collection(items)))
793793

794794

795795
def _optional_expression(

tests/cli/test_cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2305,6 +2305,24 @@ def test_lint_still_loads_state(runner: CliRunner, tmp_path: Path, mocker):
23052305
assert mock.called, "state-sync was never accessed during `lint`"
23062306

23072307

2308+
def test_lint_local_runs_without_state(runner: CliRunner, tmp_path: Path, mocker):
2309+
mock = _setup_local_only_project(tmp_path, mocker)
2310+
init_spy = mocker.spy(Context, "__init__")
2311+
2312+
result = runner.invoke(cli, ["--paths", str(tmp_path), "lint", "--local"])
2313+
2314+
assert result.exit_code == 0, f"Lint failed: {result.output}\nException: {result.exception}"
2315+
assert init_spy.called, "Context was never constructed"
2316+
for call in init_spy.call_args_list:
2317+
assert "load_state" in call.kwargs, (
2318+
"CLI didn't pass load_state= explicitly; missing kwarg defaults to True silently"
2319+
)
2320+
assert call.kwargs["load_state"] is False, (
2321+
f"Context was constructed with load_state={call.kwargs['load_state']} for `lint --local`"
2322+
)
2323+
mock.assert_not_called()
2324+
2325+
23082326
@pytest.mark.parametrize("command", ["format"])
23092327
def test_local_only_commands_skip_state_multiple_paths(
23102328
runner: CliRunner, tmp_path: Path, mocker, command: str

tests/core/test_config.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,3 +1574,66 @@ def test_load_configs_in_dbt_project_without_config_py(tmp_path: Path):
15741574
# model_defaults
15751575
assert config.model_defaults.dialect == "duckdb" # from dbt profiles.yml
15761576
assert config.model_defaults.start == "2020-01-01" # from sqlmesh.yaml
1577+
1578+
1579+
def test_canonicalize_sorts_sets() -> None:
1580+
from sqlmesh.core.config.root import _canonicalize
1581+
1582+
assert _canonicalize({3, 1, 2}) == [1, 2, 3]
1583+
assert _canonicalize(frozenset(["b", "a", "c"])) == ["a", "b", "c"]
1584+
1585+
1586+
def test_canonicalize_recurses_into_containers() -> None:
1587+
from sqlmesh.core.config.root import _canonicalize
1588+
1589+
assert _canonicalize({"rules": {"z", "a"}, "nested": [{3, 1}, ("x", {"q", "b"})]}) == {
1590+
"rules": ["a", "z"],
1591+
"nested": [[1, 3], ("x", ["b", "q"])],
1592+
}
1593+
1594+
1595+
def test_canonicalize_preserves_non_set_values_and_types() -> None:
1596+
from sqlmesh.core.config.root import _canonicalize
1597+
1598+
assert _canonicalize({"a": 1, "b": [1, 2, 3]}) == {"a": 1, "b": [1, 2, 3]}
1599+
# list/tuple types are preserved (not coerced into each other)
1600+
assert isinstance(_canonicalize((1, 2)), tuple)
1601+
assert isinstance(_canonicalize([1, 2]), list)
1602+
1603+
1604+
def test_config_fingerprint_is_deterministic_across_processes() -> None:
1605+
"""Config.fingerprint keys the on-disk model cache and must be stable across runs.
1606+
1607+
Set/frozenset iteration order depends on PYTHONHASHSEED, so a config containing a
1608+
set-valued field (e.g. linter.rules) would otherwise hash differently in each
1609+
process, silently invalidating the cache. Run the same config in two subprocesses
1610+
with different hash seeds and assert the fingerprint matches.
1611+
"""
1612+
import subprocess
1613+
import sys
1614+
1615+
program = (
1616+
"from sqlmesh.core.config import Config, ModelDefaultsConfig\n"
1617+
"from sqlmesh.core.config.linter import LinterConfig\n"
1618+
"config = Config(\n"
1619+
" model_defaults=ModelDefaultsConfig(dialect='duckdb'),\n"
1620+
" linter=LinterConfig(\n"
1621+
" enabled=True,\n"
1622+
" rules={'ruleA', 'ruleB', 'ruleC', 'ruleD', 'ruleE', 'ruleF'},\n"
1623+
" ),\n"
1624+
")\n"
1625+
"print(config.fingerprint)\n"
1626+
)
1627+
1628+
def _fingerprint(hashseed: str) -> str:
1629+
env = {**os.environ, "PYTHONHASHSEED": hashseed}
1630+
result = subprocess.run(
1631+
[sys.executable, "-c", program],
1632+
capture_output=True,
1633+
text=True,
1634+
env=env,
1635+
check=True,
1636+
)
1637+
return result.stdout.strip()
1638+
1639+
assert _fingerprint("0") == _fingerprint("12345")

0 commit comments

Comments
 (0)