equiframe 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""DuckDB compiler: native SELECT checkpoints preserve eager IR failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from equiframe.contracts import (
|
|
10
|
+
BooleanType,
|
|
11
|
+
Contract,
|
|
12
|
+
DateType,
|
|
13
|
+
DecimalType,
|
|
14
|
+
FloatType,
|
|
15
|
+
IntegerType,
|
|
16
|
+
LogicalType,
|
|
17
|
+
StringType,
|
|
18
|
+
TimestampType,
|
|
19
|
+
)
|
|
20
|
+
from equiframe.errors import CapabilityError
|
|
21
|
+
from equiframe.values import (
|
|
22
|
+
BooleanValue,
|
|
23
|
+
DecimalValue,
|
|
24
|
+
FloatValue,
|
|
25
|
+
IntegerValue,
|
|
26
|
+
NaNValue,
|
|
27
|
+
NullValue,
|
|
28
|
+
StringValue,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from ..ports import CompiledPlan, compiler_check, require_supported
|
|
32
|
+
from ..types import (
|
|
33
|
+
Aggregate,
|
|
34
|
+
Arithmetic,
|
|
35
|
+
Boolean,
|
|
36
|
+
Cast,
|
|
37
|
+
Coalesce,
|
|
38
|
+
Column,
|
|
39
|
+
Compare,
|
|
40
|
+
CompilationCheck,
|
|
41
|
+
CompilationDiagnostic,
|
|
42
|
+
Concatenate,
|
|
43
|
+
Conditional,
|
|
44
|
+
DecimalRound,
|
|
45
|
+
Deduplicate,
|
|
46
|
+
EquiJoin,
|
|
47
|
+
Expression,
|
|
48
|
+
Filter,
|
|
49
|
+
IRColumn,
|
|
50
|
+
IsNaN,
|
|
51
|
+
IsNull,
|
|
52
|
+
LiteralExpression,
|
|
53
|
+
LocalizeTimestamp,
|
|
54
|
+
Not,
|
|
55
|
+
Project,
|
|
56
|
+
RelationalPlan,
|
|
57
|
+
Scan,
|
|
58
|
+
Sort,
|
|
59
|
+
Window,
|
|
60
|
+
)
|
|
61
|
+
from ..validation import (
|
|
62
|
+
ValidatedIR,
|
|
63
|
+
compilation_identity,
|
|
64
|
+
expression_type,
|
|
65
|
+
iter_expressions,
|
|
66
|
+
references,
|
|
67
|
+
)
|
|
68
|
+
from ._timestamp_transport import _TimestampCodec, _TimestampDuckDBTransformation
|
|
69
|
+
|
|
70
|
+
_COMPILER_VERSION = "migration-native-ir1.0/duckdb/product-semantics-v9"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class _DuckDBCheckpoint:
|
|
75
|
+
name: str
|
|
76
|
+
sql: str
|
|
77
|
+
dependencies: tuple[str, ...]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class _DuckDBProgram(_TimestampDuckDBTransformation):
|
|
82
|
+
checkpoints: tuple[_DuckDBCheckpoint, ...] = ()
|
|
83
|
+
final_sql: str = ""
|
|
84
|
+
output_relation: str = ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def quote(name: str) -> str:
|
|
88
|
+
return '"' + name.replace('"', '""') + '"'
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _dtype(dtype: LogicalType) -> str:
|
|
92
|
+
if isinstance(dtype, IntegerType):
|
|
93
|
+
return ("" if dtype.signed else "U") + {
|
|
94
|
+
8: "TINYINT",
|
|
95
|
+
16: "SMALLINT",
|
|
96
|
+
32: "INTEGER",
|
|
97
|
+
64: "BIGINT",
|
|
98
|
+
}[dtype.bits]
|
|
99
|
+
if isinstance(dtype, DecimalType):
|
|
100
|
+
return f"DECIMAL({dtype.precision},{dtype.scale})"
|
|
101
|
+
if isinstance(dtype, FloatType):
|
|
102
|
+
return "FLOAT" if dtype.bits == 32 else "DOUBLE"
|
|
103
|
+
if isinstance(dtype, BooleanType):
|
|
104
|
+
return "BOOLEAN"
|
|
105
|
+
if isinstance(dtype, StringType):
|
|
106
|
+
return "VARCHAR"
|
|
107
|
+
if isinstance(dtype, DateType):
|
|
108
|
+
return "DATE"
|
|
109
|
+
if isinstance(dtype, TimestampType):
|
|
110
|
+
return "BIGINT"
|
|
111
|
+
raise CapabilityError("unsupported DuckDB IR type")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _literal(expression: LiteralExpression) -> str:
|
|
115
|
+
value = expression.value
|
|
116
|
+
if isinstance(value, NullValue):
|
|
117
|
+
text = "NULL"
|
|
118
|
+
elif isinstance(value, NaNValue):
|
|
119
|
+
text = "'NaN'"
|
|
120
|
+
elif isinstance(value, BooleanValue):
|
|
121
|
+
text = "TRUE" if value.value else "FALSE"
|
|
122
|
+
elif isinstance(value, (IntegerValue, FloatValue)):
|
|
123
|
+
text = repr(value.value)
|
|
124
|
+
elif isinstance(value, DecimalValue):
|
|
125
|
+
text = "'" + str(value.value) + "'"
|
|
126
|
+
elif isinstance(value, StringValue):
|
|
127
|
+
# An embedded NUL in SQL source terminates parsing. Construct the same
|
|
128
|
+
# VARCHAR value without putting that byte into the SQL source itself.
|
|
129
|
+
text = " || chr(0) || ".join(
|
|
130
|
+
"'" + part.replace("'", "''") + "'" for part in value.value.split("\x00")
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
raise CapabilityError("unsupported DuckDB literal")
|
|
134
|
+
return f"CAST({text} AS {_dtype(expression.logical_type)})"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _expression(
|
|
138
|
+
expression: Expression,
|
|
139
|
+
columns: tuple[IRColumn, ...],
|
|
140
|
+
prepared: Mapping[int, str] | None = None,
|
|
141
|
+
) -> str:
|
|
142
|
+
if prepared is not None and id(expression) in prepared:
|
|
143
|
+
return quote(prepared[id(expression)])
|
|
144
|
+
if isinstance(expression, Column):
|
|
145
|
+
return quote(expression.name)
|
|
146
|
+
if isinstance(expression, LiteralExpression):
|
|
147
|
+
return _literal(expression)
|
|
148
|
+
if isinstance(expression, Arithmetic):
|
|
149
|
+
left = _expression(expression.left, columns, prepared)
|
|
150
|
+
right = _expression(expression.right, columns, prepared)
|
|
151
|
+
operand = expression_type(expression.left, columns).logical_type
|
|
152
|
+
if expression.operation == "divide":
|
|
153
|
+
from .duckdb_decimal import divide
|
|
154
|
+
|
|
155
|
+
right_type = expression_type(expression.right, columns).logical_type
|
|
156
|
+
output = expression.logical_type
|
|
157
|
+
if not (
|
|
158
|
+
isinstance(operand, DecimalType)
|
|
159
|
+
and isinstance(right_type, DecimalType)
|
|
160
|
+
and isinstance(output, DecimalType)
|
|
161
|
+
):
|
|
162
|
+
raise CapabilityError("Decimal division requires declared Decimal types")
|
|
163
|
+
return divide(
|
|
164
|
+
left,
|
|
165
|
+
right,
|
|
166
|
+
left_scale=operand.scale,
|
|
167
|
+
right_scale=right_type.scale,
|
|
168
|
+
precision=output.precision,
|
|
169
|
+
scale=output.scale,
|
|
170
|
+
rounding=expression.rounding,
|
|
171
|
+
columns=tuple(column.name for column in columns),
|
|
172
|
+
)
|
|
173
|
+
if expression.operation == "multiply" and isinstance(operand, DecimalType):
|
|
174
|
+
right_type = expression_type(expression.right, columns).logical_type
|
|
175
|
+
if not (isinstance(right_type, DecimalType)):
|
|
176
|
+
raise AssertionError("Internal invariant check failed.")
|
|
177
|
+
# Widen before multiplication: DuckDB otherwise uses a DECIMAL(18)
|
|
178
|
+
# intermediate for <=18-digit operands even when the outer cast is
|
|
179
|
+
# DECIMAL(38). Preserve each original scale during promotion.
|
|
180
|
+
return (
|
|
181
|
+
f"CAST((CAST({left} AS DECIMAL(38,{operand.scale})) * "
|
|
182
|
+
f"CAST({right} AS DECIMAL(38,{right_type.scale}))) "
|
|
183
|
+
f"AS {_dtype(expression.logical_type)})"
|
|
184
|
+
)
|
|
185
|
+
intermediate = (
|
|
186
|
+
"HUGEINT" if isinstance(operand, IntegerType) else _dtype(expression.logical_type)
|
|
187
|
+
)
|
|
188
|
+
operator = {"add": "+", "subtract": "-", "multiply": "*"}[expression.operation]
|
|
189
|
+
return (
|
|
190
|
+
f"CAST((CAST({left} AS {intermediate}) {operator} "
|
|
191
|
+
f"CAST({right} AS {intermediate})) AS {_dtype(expression.logical_type)})"
|
|
192
|
+
)
|
|
193
|
+
if isinstance(expression, DecimalRound):
|
|
194
|
+
if expression.rounding == "half_even":
|
|
195
|
+
from .duckdb_decimal import divide
|
|
196
|
+
|
|
197
|
+
operand = expression_type(expression.argument, columns).logical_type
|
|
198
|
+
if not isinstance(operand, DecimalType):
|
|
199
|
+
raise CapabilityError("Decimal rounding requires a declared Decimal argument")
|
|
200
|
+
# Rounding x is exact coefficient division by one at the requested
|
|
201
|
+
# scale. Native round_even promotes DECIMAL to DOUBLE in DuckDB;
|
|
202
|
+
# the existing HUGEINT kernel preserves all 38 coefficient digits.
|
|
203
|
+
return divide(
|
|
204
|
+
_expression(expression.argument, columns, prepared),
|
|
205
|
+
"CAST(1 AS DECIMAL(1,0))",
|
|
206
|
+
left_scale=operand.scale,
|
|
207
|
+
right_scale=0,
|
|
208
|
+
precision=expression.logical_type.precision,
|
|
209
|
+
scale=expression.logical_type.scale,
|
|
210
|
+
rounding="half_even",
|
|
211
|
+
columns=tuple(column.name for column in columns),
|
|
212
|
+
)
|
|
213
|
+
return (
|
|
214
|
+
f"CAST(round({_expression(expression.argument, columns, prepared)}, "
|
|
215
|
+
f"{expression.logical_type.scale}) AS {_dtype(expression.logical_type)})"
|
|
216
|
+
)
|
|
217
|
+
if isinstance(expression, LocalizeTimestamp):
|
|
218
|
+
from .duckdb_time import localize
|
|
219
|
+
|
|
220
|
+
dtype = expression_type(expression.argument, columns).logical_type
|
|
221
|
+
if not isinstance(dtype, TimestampType):
|
|
222
|
+
raise CapabilityError("timestamp localization requires a declared timestamp argument")
|
|
223
|
+
return localize(
|
|
224
|
+
_expression(expression.argument, columns, prepared),
|
|
225
|
+
unit=dtype.unit,
|
|
226
|
+
timezone=expression.timezone,
|
|
227
|
+
ambiguous=expression.ambiguous,
|
|
228
|
+
columns=tuple(column.name for column in columns),
|
|
229
|
+
)
|
|
230
|
+
if isinstance(expression, Coalesce):
|
|
231
|
+
return (
|
|
232
|
+
"coalesce("
|
|
233
|
+
+ ", ".join(_expression(item, columns, prepared) for item in expression.arguments)
|
|
234
|
+
+ ")"
|
|
235
|
+
)
|
|
236
|
+
if isinstance(expression, Conditional):
|
|
237
|
+
return (
|
|
238
|
+
f"CASE WHEN {_expression(expression.predicate, columns, prepared)} "
|
|
239
|
+
f"THEN {_expression(expression.then, columns, prepared)} "
|
|
240
|
+
f"ELSE {_expression(expression.otherwise, columns, prepared)} END"
|
|
241
|
+
)
|
|
242
|
+
if isinstance(expression, Compare):
|
|
243
|
+
left, right = (
|
|
244
|
+
_expression(expression.left, columns, prepared),
|
|
245
|
+
_expression(expression.right, columns, prepared),
|
|
246
|
+
)
|
|
247
|
+
operator = {"eq": "=", "ne": "<>", "lt": "<", "le": "<=", "gt": ">", "ge": ">="}[
|
|
248
|
+
expression.operation
|
|
249
|
+
]
|
|
250
|
+
result = f"({left} {operator} {right})"
|
|
251
|
+
if isinstance(expression_type(expression.left, columns).logical_type, FloatType):
|
|
252
|
+
nan_value = "TRUE" if expression.operation == "ne" else "FALSE"
|
|
253
|
+
result = (
|
|
254
|
+
f"CASE WHEN {left} IS NULL OR {right} IS NULL THEN NULL "
|
|
255
|
+
f"WHEN isnan({left}) OR isnan({right}) THEN {nan_value} ELSE {result} END"
|
|
256
|
+
)
|
|
257
|
+
return "(" + result + ")"
|
|
258
|
+
if isinstance(expression, Boolean):
|
|
259
|
+
operator = " AND " if expression.operation == "and" else " OR "
|
|
260
|
+
return (
|
|
261
|
+
"("
|
|
262
|
+
+ operator.join(
|
|
263
|
+
_expression(argument, columns, prepared) for argument in expression.arguments
|
|
264
|
+
)
|
|
265
|
+
+ ")"
|
|
266
|
+
)
|
|
267
|
+
argument = _expression(expression.argument, columns, prepared)
|
|
268
|
+
if isinstance(expression, Not):
|
|
269
|
+
return f"(NOT {argument})"
|
|
270
|
+
if isinstance(expression, IsNull):
|
|
271
|
+
return f"({argument} IS NULL)"
|
|
272
|
+
if isinstance(expression, IsNaN):
|
|
273
|
+
return f"coalesce(isnan({argument}), FALSE)"
|
|
274
|
+
raise CapabilityError("unknown DuckDB expression")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _projection(validated: ValidatedIR, node_id: str) -> str:
|
|
278
|
+
expressions = []
|
|
279
|
+
for column in validated.schema(node_id):
|
|
280
|
+
value = quote(column.name)
|
|
281
|
+
if not column.nullable:
|
|
282
|
+
value = (
|
|
283
|
+
f"CASE WHEN {value} IS NULL THEN error('IR contains forbidden NULL') "
|
|
284
|
+
f"ELSE {value} END"
|
|
285
|
+
)
|
|
286
|
+
expressions.append(
|
|
287
|
+
f"CAST({value} AS {_dtype(column.logical_type)}) AS {quote(column.name)}"
|
|
288
|
+
)
|
|
289
|
+
return ", ".join(expressions)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class DuckDBCompiler:
|
|
293
|
+
engine = "duckdb"
|
|
294
|
+
|
|
295
|
+
def check(self, plan: RelationalPlan, contract: Contract) -> CompilationCheck:
|
|
296
|
+
check = compiler_check(self.engine, plan, contract)
|
|
297
|
+
if check.supported:
|
|
298
|
+
validated = require_supported(self.engine, plan, contract)
|
|
299
|
+
if any(
|
|
300
|
+
re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", table) is None
|
|
301
|
+
for table in validated.source_tables
|
|
302
|
+
) or len({table.casefold() for table in validated.source_tables}) != len(
|
|
303
|
+
validated.source_tables
|
|
304
|
+
):
|
|
305
|
+
return CompilationCheck(
|
|
306
|
+
engine=self.engine,
|
|
307
|
+
supported=False,
|
|
308
|
+
diagnostics=(
|
|
309
|
+
CompilationDiagnostic(
|
|
310
|
+
code="input_identifier",
|
|
311
|
+
message=(
|
|
312
|
+
"DuckDB inputs require unique case-insensitive ASCII identifiers"
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
for schema in validated.node_schemas:
|
|
318
|
+
if any("\x00" in column.name for column in schema.columns):
|
|
319
|
+
return CompilationCheck(
|
|
320
|
+
engine=self.engine,
|
|
321
|
+
supported=False,
|
|
322
|
+
diagnostics=(
|
|
323
|
+
CompilationDiagnostic(
|
|
324
|
+
code="nul_identifier",
|
|
325
|
+
message="DuckDB SQL identifiers cannot contain a NUL character",
|
|
326
|
+
node_id=schema.node_id,
|
|
327
|
+
),
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
names = [column.name.casefold() for column in schema.columns]
|
|
331
|
+
if len(names) != len(set(names)):
|
|
332
|
+
return CompilationCheck(
|
|
333
|
+
engine=self.engine,
|
|
334
|
+
supported=False,
|
|
335
|
+
diagnostics=(
|
|
336
|
+
CompilationDiagnostic(
|
|
337
|
+
code="case_collision",
|
|
338
|
+
message="DuckDB identifiers are case insensitive",
|
|
339
|
+
node_id=schema.node_id,
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
return check
|
|
344
|
+
|
|
345
|
+
def compile(self, plan: RelationalPlan, contract: Contract) -> CompiledPlan:
|
|
346
|
+
check = self.check(plan, contract)
|
|
347
|
+
if not check.supported:
|
|
348
|
+
raise CapabilityError(check.diagnostics[0].message)
|
|
349
|
+
validated = require_supported(self.engine, plan, contract)
|
|
350
|
+
identifier, ir_sha, contract_sha = compilation_identity(
|
|
351
|
+
validated, self.engine, implementation_version=_COMPILER_VERSION
|
|
352
|
+
)
|
|
353
|
+
prefix = "__eq_" + ir_sha[:16] + "_"
|
|
354
|
+
occupied = {table.name.casefold() for table in contract.inputs}
|
|
355
|
+
while any((prefix + str(index)).casefold() in occupied for index in range(len(plan.nodes))):
|
|
356
|
+
prefix += "_"
|
|
357
|
+
raw_names = {
|
|
358
|
+
node.node_id: prefix + str(position) for position, node in enumerate(plan.nodes)
|
|
359
|
+
}
|
|
360
|
+
names = {node_id: quote(name) for node_id, name in raw_names.items()}
|
|
361
|
+
occupied.update(name.casefold() for name in raw_names.values())
|
|
362
|
+
occupied.update(
|
|
363
|
+
c.name.casefold() for schema in validated.node_schemas for c in schema.columns
|
|
364
|
+
)
|
|
365
|
+
checkpoints: list[_DuckDBCheckpoint] = []
|
|
366
|
+
|
|
367
|
+
def fresh(kind: str) -> str:
|
|
368
|
+
name = prefix + kind + "_" + str(len(checkpoints))
|
|
369
|
+
while name.casefold() in occupied:
|
|
370
|
+
name += "_"
|
|
371
|
+
occupied.add(name.casefold())
|
|
372
|
+
return name
|
|
373
|
+
|
|
374
|
+
def prepare(
|
|
375
|
+
source_id: str, expressions: tuple[Expression, ...]
|
|
376
|
+
) -> tuple[str, dict[int, str], str | None]:
|
|
377
|
+
current = raw_names[source_id]
|
|
378
|
+
columns = validated.schema(source_id)
|
|
379
|
+
fallible = tuple(
|
|
380
|
+
item
|
|
381
|
+
for expression in expressions
|
|
382
|
+
for item in reversed(tuple(iter_expressions(expression)))
|
|
383
|
+
if isinstance(item, (Arithmetic, DecimalRound, LocalizeTimestamp))
|
|
384
|
+
)
|
|
385
|
+
prepared: dict[int, str] = {}
|
|
386
|
+
if not fallible:
|
|
387
|
+
return current, prepared, None
|
|
388
|
+
# Scalar SQL kernels can reorder rows. A native ordinal restores the
|
|
389
|
+
# observed input order after every eager expression checkpoint.
|
|
390
|
+
ordinal = fresh("order")
|
|
391
|
+
name = fresh("input")
|
|
392
|
+
checkpoints.append(
|
|
393
|
+
_DuckDBCheckpoint(
|
|
394
|
+
name,
|
|
395
|
+
f"SELECT *, row_number() OVER () AS {quote(ordinal)} FROM {quote(current)}",
|
|
396
|
+
(current,),
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
current = name
|
|
400
|
+
for item in fallible:
|
|
401
|
+
if id(item) in prepared:
|
|
402
|
+
continue
|
|
403
|
+
field = fresh("value")
|
|
404
|
+
name = fresh("expression")
|
|
405
|
+
value = _expression(item, columns, prepared)
|
|
406
|
+
dtype = _dtype(expression_type(item, columns).logical_type)
|
|
407
|
+
checkpoints.append(
|
|
408
|
+
_DuckDBCheckpoint(
|
|
409
|
+
name,
|
|
410
|
+
f"SELECT *, CAST({value} AS {dtype}) AS {quote(field)} "
|
|
411
|
+
f"FROM {quote(current)} ORDER BY {quote(ordinal)}",
|
|
412
|
+
(current,),
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
current = name
|
|
416
|
+
prepared[id(item)] = field
|
|
417
|
+
return current, prepared, ordinal
|
|
418
|
+
|
|
419
|
+
for node in plan.nodes:
|
|
420
|
+
dependencies = tuple(raw_names[ref] for ref in references(node))
|
|
421
|
+
prepared: dict[int, str] = {}
|
|
422
|
+
ordinal = None
|
|
423
|
+
expression_source = ""
|
|
424
|
+
if isinstance(node, (Project, Filter)):
|
|
425
|
+
node_expressions = (
|
|
426
|
+
tuple(item.expression for item in node.columns)
|
|
427
|
+
if isinstance(node, Project)
|
|
428
|
+
else (node.predicate,)
|
|
429
|
+
)
|
|
430
|
+
relation, prepared, ordinal = prepare(node.source, node_expressions)
|
|
431
|
+
expression_source = quote(relation)
|
|
432
|
+
dependencies = (relation,)
|
|
433
|
+
if isinstance(node, Scan):
|
|
434
|
+
dependencies = (node.table,)
|
|
435
|
+
body = f"SELECT {_projection(validated, node.node_id)} FROM {quote(node.table)}"
|
|
436
|
+
elif isinstance(node, Project):
|
|
437
|
+
project_columns = ", ".join(
|
|
438
|
+
_expression(item.expression, validated.schema(node.source), prepared)
|
|
439
|
+
+ " AS "
|
|
440
|
+
+ quote(item.name)
|
|
441
|
+
for item in node.columns
|
|
442
|
+
)
|
|
443
|
+
body = f"SELECT {project_columns} FROM {expression_source}"
|
|
444
|
+
elif isinstance(node, Filter):
|
|
445
|
+
predicate = _expression(node.predicate, validated.schema(node.source), prepared)
|
|
446
|
+
body = f"SELECT * FROM {expression_source} WHERE {predicate}"
|
|
447
|
+
elif isinstance(node, Cast):
|
|
448
|
+
body = f"SELECT {_projection(validated, node.node_id)} FROM {names[node.source]}"
|
|
449
|
+
elif isinstance(node, EquiJoin):
|
|
450
|
+
join_columns = [
|
|
451
|
+
"l." + quote(c.name) + " AS " + quote(c.name)
|
|
452
|
+
for c in validated.schema(node.left)
|
|
453
|
+
]
|
|
454
|
+
join_columns += (
|
|
455
|
+
[]
|
|
456
|
+
if node.how in ("semi", "anti")
|
|
457
|
+
else [
|
|
458
|
+
"r." + quote(c.name) + " AS " + quote(node.right_prefix + c.name)
|
|
459
|
+
for c in validated.schema(node.right)
|
|
460
|
+
]
|
|
461
|
+
)
|
|
462
|
+
operator = "IS NOT DISTINCT FROM" if node.null_keys == "equal" else "="
|
|
463
|
+
predicate = " AND ".join(
|
|
464
|
+
f"l.{quote(key.left)} {operator} r.{quote(key.right)}" for key in node.keys
|
|
465
|
+
)
|
|
466
|
+
body = (
|
|
467
|
+
f"SELECT {', '.join(join_columns)} FROM {names[node.left]} l "
|
|
468
|
+
f"{node.how.upper()} JOIN {names[node.right]} r ON {predicate}"
|
|
469
|
+
)
|
|
470
|
+
elif isinstance(node, Window):
|
|
471
|
+
partition = (
|
|
472
|
+
("PARTITION BY " + ", ".join(quote(name) for name in node.partition_by) + " ")
|
|
473
|
+
if node.partition_by
|
|
474
|
+
else ""
|
|
475
|
+
)
|
|
476
|
+
order = ", ".join(
|
|
477
|
+
quote(key.column)
|
|
478
|
+
+ (" ASC" if key.direction == "ascending" else " DESC")
|
|
479
|
+
+ " NULLS "
|
|
480
|
+
+ key.nulls.upper()
|
|
481
|
+
for key in node.by
|
|
482
|
+
)
|
|
483
|
+
window = f"OVER ({partition}ORDER BY {order})"
|
|
484
|
+
expressions = [quote(column.name) for column in validated.schema(node.source)]
|
|
485
|
+
for window_measure in node.measures:
|
|
486
|
+
if window_measure.operation == "row_number":
|
|
487
|
+
raw = "row_number()"
|
|
488
|
+
else:
|
|
489
|
+
if window_measure.column is None:
|
|
490
|
+
raise CapabilityError("lag/lead require an input column")
|
|
491
|
+
raw = (
|
|
492
|
+
f"{window_measure.operation}("
|
|
493
|
+
f"{quote(window_measure.column)}, {window_measure.offset})"
|
|
494
|
+
)
|
|
495
|
+
expressions.append(
|
|
496
|
+
f"CAST({raw} {window} AS {_dtype(window_measure.logical_type)}) "
|
|
497
|
+
f"AS {quote(window_measure.name)}"
|
|
498
|
+
)
|
|
499
|
+
body = f"SELECT {', '.join(expressions)} FROM {names[node.source]}"
|
|
500
|
+
elif isinstance(node, Aggregate):
|
|
501
|
+
aggregate_columns = [quote(name) for name in node.group_by]
|
|
502
|
+
for measure in node.measures:
|
|
503
|
+
column = quote(measure.column) if measure.column is not None else "*"
|
|
504
|
+
if measure.operation in ("count_rows", "count_valid"):
|
|
505
|
+
expression = f"count({column})"
|
|
506
|
+
elif measure.operation == "sum":
|
|
507
|
+
scale = (
|
|
508
|
+
measure.logical_type.scale
|
|
509
|
+
if isinstance(measure.logical_type, DecimalType)
|
|
510
|
+
else 0
|
|
511
|
+
)
|
|
512
|
+
raw = f"sum(CAST({column} AS DECIMAL(38,{scale})))"
|
|
513
|
+
expression = f"coalesce({raw}, 0)" if node.empty_sum == "zero" else raw
|
|
514
|
+
else:
|
|
515
|
+
expression = f"{measure.operation}({column})"
|
|
516
|
+
aggregate_columns.append(
|
|
517
|
+
f"CAST({expression} AS {_dtype(measure.logical_type)}) "
|
|
518
|
+
f"AS {quote(measure.name)}"
|
|
519
|
+
)
|
|
520
|
+
body = f"SELECT {', '.join(aggregate_columns)} FROM {names[node.source]}"
|
|
521
|
+
if node.null_groups == "drop" and node.group_by:
|
|
522
|
+
body += " WHERE " + " AND ".join(
|
|
523
|
+
quote(name) + " IS NOT NULL" for name in node.group_by
|
|
524
|
+
)
|
|
525
|
+
if node.group_by:
|
|
526
|
+
body += " GROUP BY " + ", ".join(quote(name) for name in node.group_by)
|
|
527
|
+
elif isinstance(node, Concatenate):
|
|
528
|
+
body = " UNION ALL ".join(f"SELECT * FROM {names[ref]}" for ref in node.sources)
|
|
529
|
+
elif isinstance(node, Deduplicate):
|
|
530
|
+
if node.keep == "identical":
|
|
531
|
+
body = f"SELECT DISTINCT * FROM {names[node.source]}"
|
|
532
|
+
else:
|
|
533
|
+
partition = ", ".join(quote(name) for name in node.keys)
|
|
534
|
+
order_by = ", ".join(
|
|
535
|
+
quote(key.column)
|
|
536
|
+
+ (" ASC" if key.direction == "ascending" else " DESC")
|
|
537
|
+
+ " NULLS "
|
|
538
|
+
+ key.nulls.upper()
|
|
539
|
+
for key in node.by
|
|
540
|
+
)
|
|
541
|
+
body = (
|
|
542
|
+
f"SELECT * FROM {names[node.source]} QUALIFY row_number() "
|
|
543
|
+
f"OVER (PARTITION BY {partition} ORDER BY {order_by}) = 1"
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
body = f"SELECT {_projection(validated, node.node_id)} FROM {names[node.source]}"
|
|
547
|
+
if node.ties == "reject":
|
|
548
|
+
partition = ", ".join(quote(key.column) for key in node.by)
|
|
549
|
+
body += (
|
|
550
|
+
f" QUALIFY CASE WHEN count(*) OVER (PARTITION BY {partition}) > 1 "
|
|
551
|
+
"THEN error('Sort encountered forbidden ties') ELSE TRUE END"
|
|
552
|
+
)
|
|
553
|
+
body += " ORDER BY " + ", ".join(
|
|
554
|
+
quote(key.column)
|
|
555
|
+
+ (" ASC" if key.direction == "ascending" else " DESC")
|
|
556
|
+
+ " NULLS "
|
|
557
|
+
+ key.nulls.upper()
|
|
558
|
+
for key in node.by
|
|
559
|
+
)
|
|
560
|
+
guards = []
|
|
561
|
+
if isinstance(node, Window):
|
|
562
|
+
keys = tuple(dict.fromkeys((*node.partition_by, *(key.column for key in node.by))))
|
|
563
|
+
grouped = ", ".join(quote(key) for key in keys)
|
|
564
|
+
guards.append(
|
|
565
|
+
f"EXISTS (SELECT 1 FROM {names[node.source]} "
|
|
566
|
+
f"GROUP BY {grouped} HAVING count(*) > 1)"
|
|
567
|
+
)
|
|
568
|
+
if isinstance(node, EquiJoin) and node.cardinality != "many_to_many":
|
|
569
|
+
for ref, keys, unique in (
|
|
570
|
+
(
|
|
571
|
+
node.left,
|
|
572
|
+
tuple(key.left for key in node.keys),
|
|
573
|
+
node.cardinality in ("one_to_one", "one_to_many"),
|
|
574
|
+
),
|
|
575
|
+
(
|
|
576
|
+
node.right,
|
|
577
|
+
tuple(key.right for key in node.keys),
|
|
578
|
+
node.cardinality in ("one_to_one", "many_to_one"),
|
|
579
|
+
),
|
|
580
|
+
):
|
|
581
|
+
if unique:
|
|
582
|
+
where = (
|
|
583
|
+
" WHERE " + " AND ".join(quote(key) + " IS NOT NULL" for key in keys)
|
|
584
|
+
if node.null_keys == "never"
|
|
585
|
+
else ""
|
|
586
|
+
)
|
|
587
|
+
grouped = ", ".join(quote(key) for key in keys)
|
|
588
|
+
guards.append(
|
|
589
|
+
f"EXISTS (SELECT 1 FROM {names[ref]}{where} "
|
|
590
|
+
f"GROUP BY {grouped} HAVING count(*) > 1)"
|
|
591
|
+
)
|
|
592
|
+
if isinstance(node, Deduplicate) and node.keep == "first":
|
|
593
|
+
keys = tuple(dict.fromkeys((*node.keys, *(key.column for key in node.by))))
|
|
594
|
+
grouped = ", ".join(quote(key) for key in keys)
|
|
595
|
+
guards.append(
|
|
596
|
+
f"EXISTS (SELECT 1 FROM {names[node.source]} "
|
|
597
|
+
f"GROUP BY {grouped} HAVING count(*) > 1)"
|
|
598
|
+
)
|
|
599
|
+
if guards:
|
|
600
|
+
# A separate UNION branch evaluates violations even if the join
|
|
601
|
+
# or selected input is empty. This node is consumed in full.
|
|
602
|
+
message = (
|
|
603
|
+
"Join violates declared cardinality"
|
|
604
|
+
if isinstance(node, EquiJoin)
|
|
605
|
+
else "Window encountered forbidden order ties"
|
|
606
|
+
if isinstance(node, Window)
|
|
607
|
+
else "Deduplicate encountered forbidden tiebreak ties"
|
|
608
|
+
)
|
|
609
|
+
failure = ", ".join(
|
|
610
|
+
f"CAST(error('{message}') AS {_dtype(column.logical_type)}) "
|
|
611
|
+
f"AS {quote(column.name)}"
|
|
612
|
+
for column in validated.schema(node.node_id)
|
|
613
|
+
)
|
|
614
|
+
body = f"({body}) UNION ALL SELECT {failure} WHERE " + " OR ".join(guards)
|
|
615
|
+
if ordinal is not None:
|
|
616
|
+
body += " ORDER BY " + quote(ordinal)
|
|
617
|
+
# Every node's entire declared schema is converted and consumed
|
|
618
|
+
# before a later projection/filter/join can discard its values.
|
|
619
|
+
checkpoints.append(
|
|
620
|
+
_DuckDBCheckpoint(
|
|
621
|
+
raw_names[node.node_id],
|
|
622
|
+
"SELECT "
|
|
623
|
+
+ _projection(validated, node.node_id)
|
|
624
|
+
+ " FROM ("
|
|
625
|
+
+ body
|
|
626
|
+
+ ") AS "
|
|
627
|
+
+ quote(fresh("checked")),
|
|
628
|
+
dependencies,
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
sql = "SELECT " + _projection(validated, plan.output) + " FROM " + names[plan.output]
|
|
632
|
+
output = next(node for node in plan.nodes if node.node_id == plan.output)
|
|
633
|
+
if isinstance(output, Sort):
|
|
634
|
+
sql += " ORDER BY " + ", ".join(
|
|
635
|
+
quote(key.column)
|
|
636
|
+
+ (" ASC" if key.direction == "ascending" else " DESC")
|
|
637
|
+
+ " NULLS "
|
|
638
|
+
+ key.nulls.upper()
|
|
639
|
+
for key in output.by
|
|
640
|
+
)
|
|
641
|
+
codec = _TimestampCodec.for_contract(contract)
|
|
642
|
+
transformation = _DuckDBProgram(
|
|
643
|
+
identifier,
|
|
644
|
+
"SELECT error('This IR program requires the native DuckDB checkpoint adapter')",
|
|
645
|
+
_COMPILER_VERSION,
|
|
646
|
+
timestamp_codec=codec,
|
|
647
|
+
checkpoints=tuple(checkpoints),
|
|
648
|
+
final_sql=sql,
|
|
649
|
+
output_relation=raw_names[plan.output],
|
|
650
|
+
)
|
|
651
|
+
return CompiledPlan(self.engine, transformation, ir_sha, contract_sha, check)
|