Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/sqlbuild/adapter/contract/classes/base_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,11 @@ def render_identifier(self, name: str) -> str:

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

def render_exact_identifier(self, name: str) -> str:
"""Render one explicitly quoted identifier without changing its logical spelling."""

return self.render_identifier(name)

def render_framework_type(self, type_name: FrameworkType) -> str:
"""Render one framework-internal logical type using generic SQL defaults."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ def render_identifier(self, name: str) -> str:

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

def render_exact_identifier(self, name: str) -> str:
"""Render one explicitly quoted DuckDB identifier with its exact case."""

return self.render_identifier(name)

def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
match type_name:
case LoaderLogicalType.BOOLEAN:
Expand Down
5 changes: 5 additions & 0 deletions src/sqlbuild/adapter/contract/classes/strict_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,11 @@ def render_identifier(self, name: str) -> str:
"""Render one SQL identifier for this adapter."""
...

@abstractmethod
def render_exact_identifier(self, name: str) -> str:
"""Render one explicitly quoted identifier without changing its logical spelling."""
...

@abstractmethod
def render_framework_type(self, type_name: FrameworkType) -> str:
"""Render one framework-internal logical type for this adapter."""
Expand Down
3 changes: 3 additions & 0 deletions src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,9 @@ def _bigquery_loader_rows_projection_sql(
def render_identifier(self, name: str) -> str:
return "`" + name.replace("`", "``") + "`"

def render_exact_identifier(self, name: str) -> str:
return self.render_identifier(name)

def _quote_sql_string(self, value: str) -> str:
return "'" + value.replace("'", "''") + "'"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,9 @@ def sql_analysis_dialect(self) -> str | None:
def render_identifier(self, name: str) -> str:
return "`" + name.replace("`", "``") + "`"

def render_exact_identifier(self, name: str) -> str:
return self.render_identifier(name)

def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
match type_name:
case LoaderLogicalType.BOOLEAN:
Expand Down
5 changes: 5 additions & 0 deletions src/sqlbuild/adapters/postgres/classes/postgres_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,11 @@ def render_identifier(self, name: str) -> str:

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

def render_exact_identifier(self, name: str) -> str:
"""Render one explicitly quoted PostgreSQL identifier with its exact case."""

return self.render_identifier(name)

def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
match type_name:
case LoaderLogicalType.BOOLEAN:
Expand Down
5 changes: 5 additions & 0 deletions src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,11 @@ def render_identifier(self, name: str) -> str:

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

def render_exact_identifier(self, name: str) -> str:
"""Render one explicitly quoted Snowflake identifier with its exact case."""

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

def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
match type_name:
case LoaderLogicalType.BOOLEAN:
Expand Down
3 changes: 3 additions & 0 deletions src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,9 @@ def merge(
def render_identifier(self, name: str) -> str:
return "[" + name.replace("]", "]]") + "]"

def render_exact_identifier(self, name: str) -> str:
return self.render_identifier(name)

def render_framework_type(self, type_name: FrameworkType) -> str:
match type_name:
case FrameworkType.STRING:
Expand Down
151 changes: 151 additions & 0 deletions src/sqlbuild/compiler/compile/_helpers/analysis/fixture_columns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Fixture-column inference implementation."""

from __future__ import annotations

from typing import Any, cast

from sqlbuild.adapter.contract.models import ExpressionInferenceProfile
from sqlbuild.compiler.compile._helpers.analysis.columns import (
analyze_columns_and_lineage_with_polyglot,
)
from sqlbuild.compiler.compile.constants import (
POLYGLOT_ARRAY_KIND,
POLYGLOT_FUNCTION_KIND,
POLYGLOT_LITERAL_KIND,
POLYGLOT_STRUCTURED_KINDS,
)
from sqlbuild.compiler.compile.models import (
FixtureColumnInference,
InferredColumn,
PolyglotAnalysisResult,
)
from sqlbuild.compiler.sql_analysis.constants import POLYGLOT_KIND_NULL
from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql


def infer_fixture_column_facts(
*, query_sql: str, inference_profile: ExpressionInferenceProfile
) -> FixtureColumnInference | None:
"""Infer fixture columns and identify explicitly untyped NULL projections."""

analysis: PolyglotAnalysisResult = analyze_columns_and_lineage_with_polyglot(
query_sql=query_sql,
inference_profile=inference_profile,
)
if not analysis.analysis_succeeded or analysis.has_star or analysis.columns is None:
return None
simple_types, null_literal_names, quoted_names = _simple_projection_facts(
query_sql=query_sql,
dialect=inference_profile.sql_analysis_dialect,
)
return FixtureColumnInference(
columns=tuple(
InferredColumn(
name=column.name,
type=column.type or simple_types.get(column.name.casefold()),
nullability=column.nullability,
)
for column in analysis.columns
),
null_literal_names=null_literal_names,
quoted_names=quoted_names,
)


def _simple_projection_facts(
*, query_sql: str, dialect: str | None
) -> tuple[dict[str, str], frozenset[str], frozenset[str]]:
polyglot_sql: Any = import_polyglot_sql()
try:
payload: dict[str, Any] = polyglot_sql.parse_one(
query_sql, dialect=dialect or "generic"
).to_dict()
except polyglot_sql.PolyglotError:
return {}, frozenset(), frozenset()
direct_select_payload: object = payload.get("select")
select_payload: dict[str, Any] | None = _first_select(payload)
if select_payload is None:
return {}, frozenset(), frozenset()
types, null_literal_names, quoted_names = _collect_projection_facts(
select_payload=select_payload
)
effective_null_literal_names: frozenset[str] = (
null_literal_names if isinstance(direct_select_payload, dict) else frozenset()
)
return types, effective_null_literal_names, quoted_names


def _collect_projection_facts(
*, select_payload: dict[str, Any]
) -> tuple[dict[str, str], frozenset[str], frozenset[str]]:
types: dict[str, str] = {}
null_literal_names: set[str] = set()
quoted_names: set[str] = set()
for expression in select_payload.get("expressions", []):
alias_payload: object = expression.get("alias") if isinstance(expression, dict) else None
if not isinstance(alias_payload, dict):
continue
alias: object = alias_payload.get("alias")
value: object = alias_payload.get("this")
if not isinstance(alias, dict) or not isinstance(value, dict):
continue
name: object = alias.get("name")
if not isinstance(name, str):
continue
if alias.get("quoted") is True:
quoted_names.add(name.casefold())
raw_value_kind: object = next(iter(value), "")
if not isinstance(raw_value_kind, str):
continue
value_payload: object = value.get(raw_value_kind)
if raw_value_kind == POLYGLOT_KIND_NULL:
null_literal_names.add(name.casefold())
continue
inferred_type: str | None = _simple_expression_type(
value_kind=raw_value_kind,
value_payload=value_payload,
)
if inferred_type is not None:
types[name.casefold()] = inferred_type
return types, frozenset(null_literal_names), frozenset(quoted_names)


def _simple_expression_type(*, value_kind: str, value_payload: object) -> str | None:
if value_kind == POLYGLOT_LITERAL_KIND and isinstance(value_payload, dict):
value_dict: dict[object, object] = cast(dict[object, object], value_payload)
return {
"string": "VARCHAR",
"number": "NUMBER",
"boolean": "BOOLEAN",
}.get(str(value_dict.get("literal_type")))
if value_kind == POLYGLOT_ARRAY_KIND:
return "ARRAY"
if value_kind in POLYGLOT_STRUCTURED_KINDS:
return "VARIANT"
if value_kind == POLYGLOT_FUNCTION_KIND and isinstance(value_payload, dict):
value_dict = cast(dict[object, object], value_payload)
function_name: str = str(value_dict.get("name") or "").upper()
return {
"ARRAY_CONSTRUCT": "ARRAY",
"OBJECT_CONSTRUCT": "OBJECT",
"PARSE_JSON": "VARIANT",
}.get(function_name)
return None


def _first_select(payload: object) -> dict[str, Any] | None:
if isinstance(payload, dict):
payload_dict: dict[object, object] = cast(dict[object, object], payload)
select_payload: object = payload_dict.get("select")
if isinstance(select_payload, dict):
return cast(dict[str, Any], select_payload)
for value in payload_dict.values():
found: dict[str, Any] | None = _first_select(value)
if found is not None:
return found
if isinstance(payload, list):
for value in payload:
found = _first_select(value)
if found is not None:
return found
return None
106 changes: 7 additions & 99 deletions src/sqlbuild/compiler/compile/main/_infer_fixture_columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,111 +2,19 @@

from __future__ import annotations

from typing import Any, cast

from sqlbuild.adapter.contract.models import ExpressionInferenceProfile
from sqlbuild.compiler.compile._helpers.analysis.columns import (
analyze_columns_and_lineage_with_polyglot,
)
from sqlbuild.compiler.compile.constants import (
POLYGLOT_ARRAY_KIND,
POLYGLOT_FUNCTION_KIND,
POLYGLOT_LITERAL_KIND,
POLYGLOT_STRUCTURED_KINDS,
from sqlbuild.compiler.compile._helpers.analysis.fixture_columns import (
infer_fixture_column_facts as _infer_fixture_column_facts,
)
from sqlbuild.compiler.compile.models import InferredColumn, PolyglotAnalysisResult
from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql
from sqlbuild.compiler.compile.models import FixtureColumnInference


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

analysis: PolyglotAnalysisResult = analyze_columns_and_lineage_with_polyglot(
return _infer_fixture_column_facts(
query_sql=query_sql,
inference_profile=inference_profile,
)
if not analysis.analysis_succeeded or analysis.has_star or analysis.columns is None:
return None
simple_types: dict[str, str] = _simple_projection_types(
query_sql=query_sql,
dialect=inference_profile.sql_analysis_dialect,
)
return tuple(
InferredColumn(
name=column.name,
type=column.type or simple_types.get(column.name.casefold()),
nullability=column.nullability,
)
for column in analysis.columns
)


def _simple_projection_types(*, query_sql: str, dialect: str | None) -> dict[str, str]:
polyglot_sql: Any = import_polyglot_sql()
try:
payload: dict[str, Any] = polyglot_sql.parse_one(
query_sql, dialect=dialect or "generic"
).to_dict()
except polyglot_sql.PolyglotError:
return {}
select_payload: dict[str, Any] | None = _first_select(payload)
if select_payload is None:
return {}
result: dict[str, str] = {}
for expression in select_payload.get("expressions", []):
alias_payload: object = expression.get("alias") if isinstance(expression, dict) else None
if not isinstance(alias_payload, dict):
continue
alias: object = alias_payload.get("alias")
value: object = alias_payload.get("this")
if not isinstance(alias, dict) or not isinstance(value, dict):
continue
name: object = alias.get("name")
if not isinstance(name, str):
continue
raw_value_kind: object = next(iter(value), "")
if not isinstance(raw_value_kind, str):
continue
value_kind: str = raw_value_kind
value_payload: object = value.get(value_kind)
inferred_type: str | None = None
if value_kind == POLYGLOT_LITERAL_KIND and isinstance(value_payload, dict):
inferred_type = {
"string": "VARCHAR",
"number": "NUMBER",
"boolean": "BOOLEAN",
}.get(str(value_payload.get("literal_type")))
elif value_kind == POLYGLOT_ARRAY_KIND:
inferred_type = "ARRAY"
elif value_kind in POLYGLOT_STRUCTURED_KINDS:
inferred_type = "VARIANT"
elif value_kind == POLYGLOT_FUNCTION_KIND and isinstance(value_payload, dict):
function_name: str = str(value_payload.get("name") or "").upper()
inferred_type = {
"ARRAY_CONSTRUCT": "ARRAY",
"OBJECT_CONSTRUCT": "OBJECT",
"PARSE_JSON": "VARIANT",
}.get(function_name)
if inferred_type is not None:
result[name.casefold()] = inferred_type
return result


def _first_select(payload: object) -> dict[str, Any] | None:
if isinstance(payload, dict):
payload_dict: dict[object, object] = cast(dict[object, object], payload)
select_payload: object = payload_dict.get("select")
if isinstance(select_payload, dict):
return cast(dict[str, Any], select_payload)
for value in payload_dict.values():
found: dict[str, Any] | None = _first_select(value)
if found is not None:
return found
if isinstance(payload, list):
for value in payload:
found = _first_select(value)
if found is not None:
return found
return None
9 changes: 9 additions & 0 deletions src/sqlbuild/compiler/compile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ class InferredColumn:
nullability: InferredNullability = InferredNullability.UNKNOWN


@dataclass(frozen=True)
class FixtureColumnInference:
"""Inferred fixture columns plus outputs that are explicitly bare NULL literals."""

columns: tuple[InferredColumn, ...]
null_literal_names: frozenset[str]
quoted_names: frozenset[str]


@dataclass(frozen=True)
class DynamicColumnFamilyProof:
"""Compiler evidence for one declared runtime-generated column family."""
Expand Down
Loading
Loading