Skip to content

Commit c9a26ec

Browse files
authored
Feat: add typo suggestions to MODEL block field errors (#4661)
1 parent 9e5bf54 commit c9a26ec

4 files changed

Lines changed: 139 additions & 5 deletions

File tree

sqlmesh/core/model/common.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pathlib import Path
66

77
from astor import to_source
8+
from difflib import get_close_matches
89
from sqlglot import exp
910
from sqlglot.helper import ensure_list
1011

@@ -267,13 +268,33 @@ def validate_extra_and_required_fields(
267268
) -> None:
268269
missing_required_fields = klass.missing_required_fields(provided_fields)
269270
if missing_required_fields:
271+
field_names = "'" + "', '".join(missing_required_fields) + "'"
270272
raise_config_error(
271-
f"Missing required fields {missing_required_fields} in the {entity_name}"
273+
f"Please add required field{'s' if len(missing_required_fields) > 1 else ''} {field_names} to the {entity_name}."
272274
)
273275

274276
extra_fields = klass.extra_fields(provided_fields)
275277
if extra_fields:
276-
raise_config_error(f"Invalid extra fields {extra_fields} in the {entity_name}")
278+
extra_field_names = "'" + "', '".join(extra_fields) + "'"
279+
280+
all_fields = klass.all_fields()
281+
close_matches = {}
282+
for field in extra_fields:
283+
matches = get_close_matches(field, all_fields, n=1)
284+
if matches:
285+
close_matches[field] = matches[0]
286+
287+
if len(close_matches) == 1:
288+
similar_msg = ". Did you mean " + "'" + "', '".join(close_matches.values()) + "'?"
289+
else:
290+
similar = [
291+
f"- {field}: Did you mean '{match}'?" for field, match in close_matches.items()
292+
]
293+
similar_msg = "\n\n " + "\n ".join(similar) if similar else ""
294+
295+
raise_config_error(
296+
f"Invalid field name{'s' if len(extra_fields) > 1 else ''} present in the {entity_name}: {extra_field_names}{similar_msg}"
297+
)
277298

278299

279300
def single_value_or_tuple(values: t.Sequence) -> exp.Identifier | exp.Tuple:

sqlmesh/core/model/definition.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,7 +2156,10 @@ def load_sql_based_model(
21562156
name = get_model_name(path)
21572157

21582158
if not name:
2159-
raise_config_error("Model must have a name", path)
2159+
raise_config_error(
2160+
"Please add the required 'name' field to the MODEL block at the top of the file.\n\n"
2161+
+ "Learn more at https://sqlmesh.readthedocs.io/en/stable/concepts/models/overview"
2162+
)
21602163
if "default_catalog" in meta_fields:
21612164
raise_config_error(
21622165
"`default_catalog` cannot be set on a per-model basis. It must be set at the connection level.",
@@ -2400,7 +2403,7 @@ def _create_model(
24002403
**kwargs: t.Any,
24012404
) -> Model:
24022405
validate_extra_and_required_fields(
2403-
klass, {"name", *kwargs} - {"grain", "table_properties"}, "model definition"
2406+
klass, {"name", *kwargs} - {"grain", "table_properties"}, "MODEL block"
24042407
)
24052408

24062409
for prop in PROPERTIES:

sqlmesh/core/model/kind.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1015,7 +1015,9 @@ def create_model_kind(v: t.Any, dialect: str, defaults: t.Dict[str, t.Any]) -> M
10151015
actual_kind_type, _ = custom_materialization
10161016
return actual_kind_type(**props)
10171017

1018-
validate_extra_and_required_fields(kind_type, set(props), f"model kind '{name}'")
1018+
validate_extra_and_required_fields(
1019+
kind_type, set(props), f"MODEL block 'kind {name}' field"
1020+
)
10191021
return kind_type(**props)
10201022

10211023
name = (v.name if isinstance(v, exp.Expression) else str(v)).upper()

tests/core/test_model.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,114 @@ def test_opt_out_of_time_column_in_partitioned_by():
571571
assert model.partitioned_by == [exp.to_column('"b"')]
572572

573573

574+
def test_model_no_name():
575+
expressions = d.parse(
576+
"""
577+
MODEL (
578+
dialect bigquery,
579+
);
580+
581+
SELECT 1::int AS a, 2::int AS b;
582+
"""
583+
)
584+
585+
with pytest.raises(ConfigError) as ex:
586+
load_sql_based_model(expressions)
587+
assert (
588+
str(ex.value)
589+
== "Please add the required 'name' field to the MODEL block at the top of the file.\n\nLearn more at https://sqlmesh.readthedocs.io/en/stable/concepts/models/overview"
590+
)
591+
592+
593+
def test_model_field_name_suggestions():
594+
# top-level field
595+
expressions = d.parse(
596+
"""
597+
MODEL (
598+
name db.table,
599+
dialects bigquery,
600+
);
601+
602+
SELECT 1::int AS a, 2::int AS b;
603+
"""
604+
)
605+
606+
with pytest.raises(ConfigError) as ex:
607+
load_sql_based_model(expressions)
608+
assert (
609+
str(ex.value)
610+
== "Invalid field name present in the MODEL block: 'dialects'. Did you mean 'dialect'?"
611+
)
612+
613+
# kind field
614+
expressions = d.parse(
615+
"""
616+
MODEL (
617+
name db.table,
618+
kind INCREMENTAL_BY_TIME_RANGE(
619+
time_column a,
620+
batch_sizes 1
621+
),
622+
);
623+
624+
SELECT 1::int AS a, 2::int AS b;
625+
"""
626+
)
627+
628+
with pytest.raises(ConfigError) as ex:
629+
load_sql_based_model(expressions)
630+
assert (
631+
str(ex.value)
632+
== "Invalid field name present in the MODEL block 'kind INCREMENTAL_BY_TIME_RANGE' field: 'batch_sizes'. Did you mean 'batch_size'?"
633+
)
634+
635+
# multiple fields
636+
expressions = d.parse(
637+
"""
638+
MODEL (
639+
name db.table,
640+
dialects bigquery,
641+
descriptions 'a',
642+
asdfasdf true
643+
);
644+
645+
SELECT 1::int AS a, 2::int AS b;
646+
"""
647+
)
648+
649+
with pytest.raises(ConfigError) as ex:
650+
load_sql_based_model(expressions)
651+
ex_str = str(ex.value)
652+
# field order is non-deterministic, so we can't test the output string directly
653+
assert "Invalid field names present in the MODEL block: " in ex_str
654+
assert "'descriptions'" in ex_str
655+
assert "'dialects'" in ex_str
656+
assert "'asdfasdf'" in ex_str
657+
assert "- descriptions: Did you mean 'description'?" in ex_str
658+
assert "- dialects: Did you mean 'dialect'?" in ex_str
659+
assert "- asdfasdf: Did you mean " not in ex_str
660+
661+
662+
def test_model_required_field_missing():
663+
expressions = d.parse(
664+
"""
665+
MODEL (
666+
name db.table,
667+
kind INCREMENTAL_BY_TIME_RANGE (),
668+
);
669+
670+
SELECT 1::int AS a, 2::int AS b;
671+
"""
672+
)
673+
674+
with pytest.raises(ConfigError) as ex:
675+
load_sql_based_model(expressions)
676+
assert (
677+
str(ex.value)
678+
== "Please add required field 'time_column' to the MODEL block 'kind INCREMENTAL_BY_TIME_RANGE' field."
679+
)
680+
681+
574682
def test_no_model_statement(tmp_path: Path):
575683
# No name inference => MODEL (...) is required
576684
expressions = d.parse("SELECT 1 AS x")

0 commit comments

Comments
 (0)