Skip to content

Commit 7ca2da3

Browse files
authored
fix: type null-only SQL test fixtures (#754)
1 parent 373b923 commit 7ca2da3

19 files changed

Lines changed: 482 additions & 108 deletions

File tree

‎src/sqlbuild/adapter/contract/classes/base_adapter.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1954,6 +1954,11 @@ def render_identifier(self, name: str) -> str:
19541954

19551955
return '"' + name.replace('"', '""') + '"'
19561956

1957+
def render_exact_identifier(self, name: str) -> str:
1958+
"""Render one explicitly quoted identifier without changing its logical spelling."""
1959+
1960+
return self.render_identifier(name)
1961+
19571962
def render_framework_type(self, type_name: FrameworkType) -> str:
19581963
"""Render one framework-internal logical type using generic SQL defaults."""
19591964

‎src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ def render_identifier(self, name: str) -> str:
291291

292292
return '"' + name.replace('"', '""') + '"'
293293

294+
def render_exact_identifier(self, name: str) -> str:
295+
"""Render one explicitly quoted DuckDB identifier with its exact case."""
296+
297+
return self.render_identifier(name)
298+
294299
def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
295300
match type_name:
296301
case LoaderLogicalType.BOOLEAN:

‎src/sqlbuild/adapter/contract/classes/strict_adapter.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,11 @@ def render_identifier(self, name: str) -> str:
680680
"""Render one SQL identifier for this adapter."""
681681
...
682682

683+
@abstractmethod
684+
def render_exact_identifier(self, name: str) -> str:
685+
"""Render one explicitly quoted identifier without changing its logical spelling."""
686+
...
687+
683688
@abstractmethod
684689
def render_framework_type(self, type_name: FrameworkType) -> str:
685690
"""Render one framework-internal logical type for this adapter."""

‎src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,9 @@ def _bigquery_loader_rows_projection_sql(
972972
def render_identifier(self, name: str) -> str:
973973
return "`" + name.replace("`", "``") + "`"
974974

975+
def render_exact_identifier(self, name: str) -> str:
976+
return self.render_identifier(name)
977+
975978
def _quote_sql_string(self, value: str) -> str:
976979
return "'" + value.replace("'", "''") + "'"
977980

‎src/sqlbuild/adapters/databricks/classes/databricks_adapter.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,9 @@ def sql_analysis_dialect(self) -> str | None:
728728
def render_identifier(self, name: str) -> str:
729729
return "`" + name.replace("`", "``") + "`"
730730

731+
def render_exact_identifier(self, name: str) -> str:
732+
return self.render_identifier(name)
733+
731734
def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
732735
match type_name:
733736
case LoaderLogicalType.BOOLEAN:

‎src/sqlbuild/adapters/postgres/classes/postgres_adapter.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1660,6 +1660,11 @@ def render_identifier(self, name: str) -> str:
16601660

16611661
return '"' + name.replace('"', '""') + '"'
16621662

1663+
def render_exact_identifier(self, name: str) -> str:
1664+
"""Render one explicitly quoted PostgreSQL identifier with its exact case."""
1665+
1666+
return self.render_identifier(name)
1667+
16631668
def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
16641669
match type_name:
16651670
case LoaderLogicalType.BOOLEAN:

‎src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,11 @@ def render_identifier(self, name: str) -> str:
10641064

10651065
return '"' + name.upper().replace('"', '""') + '"'
10661066

1067+
def render_exact_identifier(self, name: str) -> str:
1068+
"""Render one explicitly quoted Snowflake identifier with its exact case."""
1069+
1070+
return '"' + name.replace('"', '""') + '"'
1071+
10671072
def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
10681073
match type_name:
10691074
case LoaderLogicalType.BOOLEAN:

‎src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,9 @@ def merge(
954954
def render_identifier(self, name: str) -> str:
955955
return "[" + name.replace("]", "]]") + "]"
956956

957+
def render_exact_identifier(self, name: str) -> str:
958+
return self.render_identifier(name)
959+
957960
def render_framework_type(self, type_name: FrameworkType) -> str:
958961
match type_name:
959962
case FrameworkType.STRING:
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Fixture-column inference implementation."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, cast
6+
7+
from sqlbuild.adapter.contract.models import ExpressionInferenceProfile
8+
from sqlbuild.compiler.compile._helpers.analysis.columns import (
9+
analyze_columns_and_lineage_with_polyglot,
10+
)
11+
from sqlbuild.compiler.compile.constants import (
12+
POLYGLOT_ARRAY_KIND,
13+
POLYGLOT_FUNCTION_KIND,
14+
POLYGLOT_LITERAL_KIND,
15+
POLYGLOT_STRUCTURED_KINDS,
16+
)
17+
from sqlbuild.compiler.compile.models import (
18+
FixtureColumnInference,
19+
InferredColumn,
20+
PolyglotAnalysisResult,
21+
)
22+
from sqlbuild.compiler.sql_analysis.constants import POLYGLOT_KIND_NULL
23+
from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql
24+
25+
26+
def infer_fixture_column_facts(
27+
*, query_sql: str, inference_profile: ExpressionInferenceProfile
28+
) -> FixtureColumnInference | None:
29+
"""Infer fixture columns and identify explicitly untyped NULL projections."""
30+
31+
analysis: PolyglotAnalysisResult = analyze_columns_and_lineage_with_polyglot(
32+
query_sql=query_sql,
33+
inference_profile=inference_profile,
34+
)
35+
if not analysis.analysis_succeeded or analysis.has_star or analysis.columns is None:
36+
return None
37+
simple_types, null_literal_names, quoted_names = _simple_projection_facts(
38+
query_sql=query_sql,
39+
dialect=inference_profile.sql_analysis_dialect,
40+
)
41+
return FixtureColumnInference(
42+
columns=tuple(
43+
InferredColumn(
44+
name=column.name,
45+
type=column.type or simple_types.get(column.name.casefold()),
46+
nullability=column.nullability,
47+
)
48+
for column in analysis.columns
49+
),
50+
null_literal_names=null_literal_names,
51+
quoted_names=quoted_names,
52+
)
53+
54+
55+
def _simple_projection_facts(
56+
*, query_sql: str, dialect: str | None
57+
) -> tuple[dict[str, str], frozenset[str], frozenset[str]]:
58+
polyglot_sql: Any = import_polyglot_sql()
59+
try:
60+
payload: dict[str, Any] = polyglot_sql.parse_one(
61+
query_sql, dialect=dialect or "generic"
62+
).to_dict()
63+
except polyglot_sql.PolyglotError:
64+
return {}, frozenset(), frozenset()
65+
direct_select_payload: object = payload.get("select")
66+
select_payload: dict[str, Any] | None = _first_select(payload)
67+
if select_payload is None:
68+
return {}, frozenset(), frozenset()
69+
types, null_literal_names, quoted_names = _collect_projection_facts(
70+
select_payload=select_payload
71+
)
72+
effective_null_literal_names: frozenset[str] = (
73+
null_literal_names if isinstance(direct_select_payload, dict) else frozenset()
74+
)
75+
return types, effective_null_literal_names, quoted_names
76+
77+
78+
def _collect_projection_facts(
79+
*, select_payload: dict[str, Any]
80+
) -> tuple[dict[str, str], frozenset[str], frozenset[str]]:
81+
types: dict[str, str] = {}
82+
null_literal_names: set[str] = set()
83+
quoted_names: set[str] = set()
84+
for expression in select_payload.get("expressions", []):
85+
alias_payload: object = expression.get("alias") if isinstance(expression, dict) else None
86+
if not isinstance(alias_payload, dict):
87+
continue
88+
alias: object = alias_payload.get("alias")
89+
value: object = alias_payload.get("this")
90+
if not isinstance(alias, dict) or not isinstance(value, dict):
91+
continue
92+
name: object = alias.get("name")
93+
if not isinstance(name, str):
94+
continue
95+
if alias.get("quoted") is True:
96+
quoted_names.add(name.casefold())
97+
raw_value_kind: object = next(iter(value), "")
98+
if not isinstance(raw_value_kind, str):
99+
continue
100+
value_payload: object = value.get(raw_value_kind)
101+
if raw_value_kind == POLYGLOT_KIND_NULL:
102+
null_literal_names.add(name.casefold())
103+
continue
104+
inferred_type: str | None = _simple_expression_type(
105+
value_kind=raw_value_kind,
106+
value_payload=value_payload,
107+
)
108+
if inferred_type is not None:
109+
types[name.casefold()] = inferred_type
110+
return types, frozenset(null_literal_names), frozenset(quoted_names)
111+
112+
113+
def _simple_expression_type(*, value_kind: str, value_payload: object) -> str | None:
114+
if value_kind == POLYGLOT_LITERAL_KIND and isinstance(value_payload, dict):
115+
value_dict: dict[object, object] = cast(dict[object, object], value_payload)
116+
return {
117+
"string": "VARCHAR",
118+
"number": "NUMBER",
119+
"boolean": "BOOLEAN",
120+
}.get(str(value_dict.get("literal_type")))
121+
if value_kind == POLYGLOT_ARRAY_KIND:
122+
return "ARRAY"
123+
if value_kind in POLYGLOT_STRUCTURED_KINDS:
124+
return "VARIANT"
125+
if value_kind == POLYGLOT_FUNCTION_KIND and isinstance(value_payload, dict):
126+
value_dict = cast(dict[object, object], value_payload)
127+
function_name: str = str(value_dict.get("name") or "").upper()
128+
return {
129+
"ARRAY_CONSTRUCT": "ARRAY",
130+
"OBJECT_CONSTRUCT": "OBJECT",
131+
"PARSE_JSON": "VARIANT",
132+
}.get(function_name)
133+
return None
134+
135+
136+
def _first_select(payload: object) -> dict[str, Any] | None:
137+
if isinstance(payload, dict):
138+
payload_dict: dict[object, object] = cast(dict[object, object], payload)
139+
select_payload: object = payload_dict.get("select")
140+
if isinstance(select_payload, dict):
141+
return cast(dict[str, Any], select_payload)
142+
for value in payload_dict.values():
143+
found: dict[str, Any] | None = _first_select(value)
144+
if found is not None:
145+
return found
146+
if isinstance(payload, list):
147+
for value in payload:
148+
found = _first_select(value)
149+
if found is not None:
150+
return found
151+
return None

‎src/sqlbuild/compiler/compile/main/_infer_fixture_columns.py‎

Lines changed: 7 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -2,111 +2,19 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, cast
6-
75
from sqlbuild.adapter.contract.models import ExpressionInferenceProfile
8-
from sqlbuild.compiler.compile._helpers.analysis.columns import (
9-
analyze_columns_and_lineage_with_polyglot,
10-
)
11-
from sqlbuild.compiler.compile.constants import (
12-
POLYGLOT_ARRAY_KIND,
13-
POLYGLOT_FUNCTION_KIND,
14-
POLYGLOT_LITERAL_KIND,
15-
POLYGLOT_STRUCTURED_KINDS,
6+
from sqlbuild.compiler.compile._helpers.analysis.fixture_columns import (
7+
infer_fixture_column_facts as _infer_fixture_column_facts,
168
)
17-
from sqlbuild.compiler.compile.models import InferredColumn, PolyglotAnalysisResult
18-
from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql
9+
from sqlbuild.compiler.compile.models import FixtureColumnInference
1910

2011

21-
def infer_fixture_columns(
12+
def infer_fixture_column_facts(
2213
*, query_sql: str, inference_profile: ExpressionInferenceProfile
23-
) -> tuple[InferredColumn, ...] | None:
24-
"""Infer fixture columns and conservative simple-literal types without warehouse access."""
14+
) -> FixtureColumnInference | None:
15+
"""Infer fixture columns and identify explicitly untyped NULL projections."""
2516

26-
analysis: PolyglotAnalysisResult = analyze_columns_and_lineage_with_polyglot(
17+
return _infer_fixture_column_facts(
2718
query_sql=query_sql,
2819
inference_profile=inference_profile,
2920
)
30-
if not analysis.analysis_succeeded or analysis.has_star or analysis.columns is None:
31-
return None
32-
simple_types: dict[str, str] = _simple_projection_types(
33-
query_sql=query_sql,
34-
dialect=inference_profile.sql_analysis_dialect,
35-
)
36-
return tuple(
37-
InferredColumn(
38-
name=column.name,
39-
type=column.type or simple_types.get(column.name.casefold()),
40-
nullability=column.nullability,
41-
)
42-
for column in analysis.columns
43-
)
44-
45-
46-
def _simple_projection_types(*, query_sql: str, dialect: str | None) -> dict[str, str]:
47-
polyglot_sql: Any = import_polyglot_sql()
48-
try:
49-
payload: dict[str, Any] = polyglot_sql.parse_one(
50-
query_sql, dialect=dialect or "generic"
51-
).to_dict()
52-
except polyglot_sql.PolyglotError:
53-
return {}
54-
select_payload: dict[str, Any] | None = _first_select(payload)
55-
if select_payload is None:
56-
return {}
57-
result: dict[str, str] = {}
58-
for expression in select_payload.get("expressions", []):
59-
alias_payload: object = expression.get("alias") if isinstance(expression, dict) else None
60-
if not isinstance(alias_payload, dict):
61-
continue
62-
alias: object = alias_payload.get("alias")
63-
value: object = alias_payload.get("this")
64-
if not isinstance(alias, dict) or not isinstance(value, dict):
65-
continue
66-
name: object = alias.get("name")
67-
if not isinstance(name, str):
68-
continue
69-
raw_value_kind: object = next(iter(value), "")
70-
if not isinstance(raw_value_kind, str):
71-
continue
72-
value_kind: str = raw_value_kind
73-
value_payload: object = value.get(value_kind)
74-
inferred_type: str | None = None
75-
if value_kind == POLYGLOT_LITERAL_KIND and isinstance(value_payload, dict):
76-
inferred_type = {
77-
"string": "VARCHAR",
78-
"number": "NUMBER",
79-
"boolean": "BOOLEAN",
80-
}.get(str(value_payload.get("literal_type")))
81-
elif value_kind == POLYGLOT_ARRAY_KIND:
82-
inferred_type = "ARRAY"
83-
elif value_kind in POLYGLOT_STRUCTURED_KINDS:
84-
inferred_type = "VARIANT"
85-
elif value_kind == POLYGLOT_FUNCTION_KIND and isinstance(value_payload, dict):
86-
function_name: str = str(value_payload.get("name") or "").upper()
87-
inferred_type = {
88-
"ARRAY_CONSTRUCT": "ARRAY",
89-
"OBJECT_CONSTRUCT": "OBJECT",
90-
"PARSE_JSON": "VARIANT",
91-
}.get(function_name)
92-
if inferred_type is not None:
93-
result[name.casefold()] = inferred_type
94-
return result
95-
96-
97-
def _first_select(payload: object) -> dict[str, Any] | None:
98-
if isinstance(payload, dict):
99-
payload_dict: dict[object, object] = cast(dict[object, object], payload)
100-
select_payload: object = payload_dict.get("select")
101-
if isinstance(select_payload, dict):
102-
return cast(dict[str, Any], select_payload)
103-
for value in payload_dict.values():
104-
found: dict[str, Any] | None = _first_select(value)
105-
if found is not None:
106-
return found
107-
if isinstance(payload, list):
108-
for value in payload:
109-
found = _first_select(value)
110-
if found is not None:
111-
return found
112-
return None

0 commit comments

Comments
 (0)