Skip to content

Commit e91c9b9

Browse files
fix: formatter function casing (#5903)
Signed-off-by: Alberto Suman <alberto.suman@1komma5grad.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 80d6fa6 commit e91c9b9

4 files changed

Lines changed: 263 additions & 13 deletions

File tree

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/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/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

tests/core/test_dialect.py

Lines changed: 226 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import sqlmesh.core.dialect as d
1616
from sqlmesh.core.model import SqlModel, load_sql_based_model
1717
from sqlmesh.core.config.connection import DIALECT_TO_TYPE
18+
from sqlmesh.core.config.format import FormatConfig
1819

1920
pytestmark = pytest.mark.dialect_isolated
2021

@@ -98,7 +99,7 @@ def test_format_model_expressions():
9899
references (a, (b, c) AS d), /* c */
99100
@macro_prop_with_comment(proper := 'foo'), /* k */
100101
audits ARRAY(
101-
NOT_NULL(
102+
not_null(
102103
columns = ARRAY(
103104
foo_id,
104105
foo_normalised,
@@ -112,14 +113,14 @@ def test_format_model_expressions():
112113
tier
113114
)
114115
),
115-
UNIQUE_VALUES(columns = ARRAY(foo_id)),
116-
ACCEPTED_RANGE(column = foo_normalised, min_v = 0, max_v = 100),
117-
ACCEPTED_RANGE(column = bar_normalised, min_v = 0, max_v = 100),
118-
ACCEPTED_RANGE(column = total_weight, min_v = 0, max_v = 100),
119-
ACCEPTED_RANGE(column = cumulative_total_weight_share, min_v = 0, max_v = 1),
120-
ACCEPTED_RANGE(column = market_cumulative_total_weight_share, min_v = 0, max_v = 1),
121-
ACCEPTED_VALUES(column = tier, is_in = ARRAY('Tier 1', 'Tier 2', 'Tier 3', 'Long Tail')),
122-
ACCEPTED_VALUES(
116+
unique_values(columns = ARRAY(foo_id)),
117+
accepted_range(column = foo_normalised, min_v = 0, max_v = 100),
118+
accepted_range(column = bar_normalised, min_v = 0, max_v = 100),
119+
accepted_range(column = total_weight, min_v = 0, max_v = 100),
120+
accepted_range(column = cumulative_total_weight_share, min_v = 0, max_v = 1),
121+
accepted_range(column = market_cumulative_total_weight_share, min_v = 0, max_v = 1),
122+
accepted_values(column = tier, is_in = ARRAY('Tier 1', 'Tier 2', 'Tier 3', 'Long Tail')),
123+
accepted_values(
123124
column = total_weight_decile,
124125
is_in = ARRAY(
125126
'Decile_01',
@@ -341,6 +342,222 @@ def test_format_model_expressions():
341342
)
342343

343344

345+
def test_format_model_expressions_normalize_functions():
346+
"""Regression: formatter function-name casing behavior.
347+
348+
Approved behavior:
349+
- Default (``normalize_functions=False``): custom/audit function names
350+
(stored as strings in the AST) are preserved with their original casing.
351+
SQLGlot built-in functions like COUNT/SUM are always output in their
352+
canonical uppercase form because the parser discards the original spelling.
353+
- ``normalize_functions="upper"``: both audit references and query functions
354+
are uppercased.
355+
- ``normalize_functions="lower"``: both audit references and query functions
356+
are lowercased.
357+
358+
The fix also covers the single-meta-expression early-return path in
359+
``format_model_expressions``; assertions at the end of this test exercise
360+
that path to prevent regression.
361+
"""
362+
expressions = parse(
363+
"""
364+
MODEL (
365+
name x,
366+
audits (
367+
unique_combination_of_columns(columns := (id)),
368+
not_null(columns := (id))
369+
)
370+
);
371+
372+
SELECT SUM(id), count(id) FROM foo;
373+
"""
374+
)
375+
376+
# Default: audit references preserved lowercase; COUNT/SUM canonicalized uppercase.
377+
assert (
378+
format_model_expressions(expressions)
379+
== """MODEL (
380+
name x,
381+
audits (
382+
unique_combination_of_columns(columns := (
383+
id
384+
)),
385+
not_null(columns := (
386+
id
387+
))
388+
)
389+
);
390+
391+
SELECT
392+
SUM(id),
393+
COUNT(id)
394+
FROM foo"""
395+
)
396+
397+
# "upper": audit references uppercased; query functions uppercased.
398+
assert (
399+
format_model_expressions(expressions, normalize_functions="upper")
400+
== """MODEL (
401+
name x,
402+
audits (
403+
UNIQUE_COMBINATION_OF_COLUMNS(columns := (
404+
id
405+
)),
406+
NOT_NULL(columns := (
407+
id
408+
))
409+
)
410+
);
411+
412+
SELECT
413+
SUM(id),
414+
COUNT(id)
415+
FROM foo"""
416+
)
417+
418+
# "lower": audit references preserved lowercase (already lower); query functions lowercased.
419+
assert (
420+
format_model_expressions(expressions, normalize_functions="lower")
421+
== """MODEL (
422+
name x,
423+
audits (
424+
unique_combination_of_columns(columns := (
425+
id
426+
)),
427+
not_null(columns := (
428+
id
429+
))
430+
)
431+
);
432+
433+
SELECT
434+
sum(id),
435+
count(id)
436+
FROM foo"""
437+
)
438+
439+
# None: explicit deferral to SQLGlot default → custom/audit names uppercased,
440+
# just like "upper". This is distinct from False (preserve) and must be tested
441+
# explicitly because None used to be indistinguishable from the missing kwarg.
442+
assert (
443+
format_model_expressions(expressions, normalize_functions=None)
444+
== """MODEL (
445+
name x,
446+
audits (
447+
UNIQUE_COMBINATION_OF_COLUMNS(columns := (
448+
id
449+
)),
450+
NOT_NULL(columns := (
451+
id
452+
))
453+
)
454+
);
455+
456+
SELECT
457+
SUM(id),
458+
COUNT(id)
459+
FROM foo"""
460+
)
461+
462+
# Single-meta-expression path: normalize_functions must be forwarded.
463+
# Without the fix, this path ignored normalize_functions entirely.
464+
single_model = parse(
465+
"""
466+
MODEL (
467+
name x,
468+
audits (
469+
unique_combination_of_columns(columns := (id)),
470+
not_null(columns := (id))
471+
)
472+
);
473+
"""
474+
)
475+
476+
assert (
477+
format_model_expressions(single_model, normalize_functions="upper")
478+
== """MODEL (
479+
name x,
480+
audits (
481+
UNIQUE_COMBINATION_OF_COLUMNS(columns := (
482+
id
483+
)),
484+
NOT_NULL(columns := (
485+
id
486+
))
487+
)
488+
)"""
489+
)
490+
491+
assert (
492+
format_model_expressions(single_model)
493+
== """MODEL (
494+
name x,
495+
audits (
496+
unique_combination_of_columns(columns := (
497+
id
498+
)),
499+
not_null(columns := (
500+
id
501+
))
502+
)
503+
)"""
504+
)
505+
506+
# Single-meta path, None: custom audit names are uppercased (explicit SQLGlot default deferral).
507+
assert (
508+
format_model_expressions(single_model, normalize_functions=None)
509+
== """MODEL (
510+
name x,
511+
audits (
512+
UNIQUE_COMBINATION_OF_COLUMNS(columns := (
513+
id
514+
)),
515+
NOT_NULL(columns := (
516+
id
517+
))
518+
)
519+
)"""
520+
)
521+
522+
523+
def test_format_config_normalize_functions_false():
524+
config = FormatConfig(normalize_functions=False)
525+
526+
assert config.normalize_functions is False
527+
assert config.generator_options["normalize_functions"] is False
528+
529+
530+
def test_format_config_normalize_functions_none():
531+
"""FormatConfig(normalize_functions=None) must be accepted but excluded from
532+
generator_options by Pydantic's exclude_none serialization. The config-layer
533+
null therefore takes the False-default path in format_model_expressions rather
534+
than deferring to SQLGlot's generator default the way True does.
535+
"""
536+
config = FormatConfig(normalize_functions=None)
537+
538+
assert config.normalize_functions is None
539+
# None is excluded by PydanticModel.dict(exclude_none=True), so the key must
540+
# be absent from generator_options — format_model_expressions will use False.
541+
assert "normalize_functions" not in config.generator_options
542+
543+
# Confirm the False-default behaviour: custom audit names must be preserved.
544+
expressions = parse(
545+
"""
546+
MODEL (
547+
name x,
548+
audits (
549+
unique_combination_of_columns(columns := (id)),
550+
not_null(columns := (id))
551+
)
552+
);
553+
SELECT id FROM foo
554+
"""
555+
)
556+
result = format_model_expressions(expressions, **config.generator_options)
557+
assert "unique_combination_of_columns" in result
558+
assert "not_null" in result
559+
560+
344561
def test_macro_format():
345562
assert parse_one("@EACH(ARRAY(1,2), x -> x)").sql() == "@EACH(ARRAY(1, 2), x -> x)"
346563
assert parse_one("INTERVAL @x DAY").sql() == "INTERVAL @x DAY"

0 commit comments

Comments
 (0)