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,619 @@
|
|
|
1
|
+
"""Pure IR inference and shared restrictions, without engine imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from decimal import Decimal, localcontext
|
|
10
|
+
|
|
11
|
+
from equiframe._decimal import exact_decimal_context
|
|
12
|
+
from equiframe.contracts import (
|
|
13
|
+
BooleanType,
|
|
14
|
+
Contract,
|
|
15
|
+
DecimalType,
|
|
16
|
+
FloatType,
|
|
17
|
+
IntegerType,
|
|
18
|
+
LogicalType,
|
|
19
|
+
SignificantOrder,
|
|
20
|
+
StringType,
|
|
21
|
+
TimestampType,
|
|
22
|
+
)
|
|
23
|
+
from equiframe.errors import CapabilityError
|
|
24
|
+
from equiframe.serialization import contract_fingerprint
|
|
25
|
+
from equiframe.values import (
|
|
26
|
+
BooleanValue,
|
|
27
|
+
DecimalValue,
|
|
28
|
+
FloatValue,
|
|
29
|
+
IntegerValue,
|
|
30
|
+
NaNValue,
|
|
31
|
+
NullValue,
|
|
32
|
+
StringValue,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from .types import (
|
|
36
|
+
Aggregate,
|
|
37
|
+
Arithmetic,
|
|
38
|
+
Boolean,
|
|
39
|
+
Cast,
|
|
40
|
+
Coalesce,
|
|
41
|
+
Column,
|
|
42
|
+
Compare,
|
|
43
|
+
Concatenate,
|
|
44
|
+
Conditional,
|
|
45
|
+
DecimalRound,
|
|
46
|
+
Deduplicate,
|
|
47
|
+
EquiJoin,
|
|
48
|
+
Expression,
|
|
49
|
+
Filter,
|
|
50
|
+
IRColumn,
|
|
51
|
+
IsNaN,
|
|
52
|
+
LiteralExpression,
|
|
53
|
+
LocalizeTimestamp,
|
|
54
|
+
Node,
|
|
55
|
+
NodeSchema,
|
|
56
|
+
Not,
|
|
57
|
+
Project,
|
|
58
|
+
RelationalPlan,
|
|
59
|
+
Scan,
|
|
60
|
+
Window,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ValidatedIR:
|
|
66
|
+
plan: RelationalPlan
|
|
67
|
+
contract: Contract
|
|
68
|
+
node_schemas: tuple[NodeSchema, ...]
|
|
69
|
+
source_tables: tuple[str, ...]
|
|
70
|
+
output_columns: tuple[IRColumn, ...]
|
|
71
|
+
|
|
72
|
+
def schema(self, node_id: str) -> tuple[IRColumn, ...]:
|
|
73
|
+
return next(s.columns for s in self.node_schemas if s.node_id == node_id)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def ir_fingerprint(plan: RelationalPlan) -> str:
|
|
77
|
+
plan = RelationalPlan.model_validate(plan)
|
|
78
|
+
return hashlib.sha256(
|
|
79
|
+
json.dumps(
|
|
80
|
+
plan.model_dump(mode="json"),
|
|
81
|
+
sort_keys=True,
|
|
82
|
+
separators=(",", ":"),
|
|
83
|
+
ensure_ascii=False,
|
|
84
|
+
allow_nan=False,
|
|
85
|
+
).encode()
|
|
86
|
+
).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def integer_bounds(dtype: IntegerType) -> tuple[int, int]:
|
|
90
|
+
return (
|
|
91
|
+
(-(2 ** (dtype.bits - 1)), 2 ** (dtype.bits - 1) - 1)
|
|
92
|
+
if dtype.signed
|
|
93
|
+
else (0, 2**dtype.bits - 1)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def decimal_limit(dtype: DecimalType) -> Decimal:
|
|
98
|
+
with localcontext(exact_decimal_context()):
|
|
99
|
+
return Decimal(10) ** (dtype.precision - dtype.scale) - Decimal(10) ** -dtype.scale
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unique(names: tuple[str, ...]) -> None:
|
|
103
|
+
if len(names) != len(set(names)):
|
|
104
|
+
raise ValueError("IR names/keys must be unique")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _column(name: str, columns: tuple[IRColumn, ...]) -> IRColumn:
|
|
108
|
+
for column in columns:
|
|
109
|
+
if column.name == name:
|
|
110
|
+
return column
|
|
111
|
+
raise ValueError("unknown IR column: " + name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _literal(expression: LiteralExpression) -> IRColumn:
|
|
115
|
+
dtype, value = expression.logical_type, expression.value
|
|
116
|
+
valid = isinstance(value, NullValue)
|
|
117
|
+
if isinstance(dtype, BooleanType):
|
|
118
|
+
valid |= isinstance(value, BooleanValue)
|
|
119
|
+
elif isinstance(dtype, IntegerType) and isinstance(value, IntegerValue):
|
|
120
|
+
lower, upper = integer_bounds(dtype)
|
|
121
|
+
valid = lower <= value.value <= upper
|
|
122
|
+
elif isinstance(dtype, DecimalType) and isinstance(value, DecimalValue):
|
|
123
|
+
with localcontext(exact_decimal_context()):
|
|
124
|
+
exponent = value.value.as_tuple().exponent
|
|
125
|
+
if not (isinstance(exponent, int)):
|
|
126
|
+
raise AssertionError("Internal invariant check failed.")
|
|
127
|
+
valid = (
|
|
128
|
+
exponent >= -dtype.scale
|
|
129
|
+
and not (value.value.is_zero() and value.value.is_signed())
|
|
130
|
+
and value.value.copy_abs() <= decimal_limit(dtype)
|
|
131
|
+
and value.value == value.value.quantize(Decimal(10) ** -dtype.scale)
|
|
132
|
+
)
|
|
133
|
+
elif isinstance(dtype, FloatType):
|
|
134
|
+
valid |= isinstance(value, (FloatValue, NaNValue))
|
|
135
|
+
if dtype.bits == 32 and isinstance(value, FloatValue):
|
|
136
|
+
import struct
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
valid = struct.unpack("f", struct.pack("f", value.value))[0] == value.value
|
|
140
|
+
except OverflowError:
|
|
141
|
+
valid = False
|
|
142
|
+
elif isinstance(dtype, StringType):
|
|
143
|
+
valid |= isinstance(value, StringValue)
|
|
144
|
+
if not valid:
|
|
145
|
+
raise ValueError("literal does not fit its exact declared type")
|
|
146
|
+
return IRColumn(
|
|
147
|
+
name="literal",
|
|
148
|
+
logical_type=dtype,
|
|
149
|
+
nullable=isinstance(value, NullValue),
|
|
150
|
+
unit=expression.unit,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def expression_type(
|
|
155
|
+
expression: Expression, columns: tuple[IRColumn, ...], *, depth: int = 0
|
|
156
|
+
) -> IRColumn:
|
|
157
|
+
if depth > 32:
|
|
158
|
+
raise ValueError("IR expression depth exceeds32")
|
|
159
|
+
if isinstance(expression, Column):
|
|
160
|
+
return _column(expression.name, columns)
|
|
161
|
+
if isinstance(expression, LiteralExpression):
|
|
162
|
+
return _literal(expression)
|
|
163
|
+
if isinstance(expression, Arithmetic):
|
|
164
|
+
left = expression_type(expression.left, columns, depth=depth + 1)
|
|
165
|
+
right = expression_type(expression.right, columns, depth=depth + 1)
|
|
166
|
+
dtype = expression.logical_type
|
|
167
|
+
if isinstance(dtype, DecimalType) and dtype.precision > 38:
|
|
168
|
+
raise CapabilityError("native Decimal arithmetic supports precision<=38")
|
|
169
|
+
operand = left.logical_type
|
|
170
|
+
if expression.operation in ("add", "subtract") or isinstance(operand, IntegerType):
|
|
171
|
+
if left.logical_type != right.logical_type:
|
|
172
|
+
raise CapabilityError("arithmetic requires identical operand types")
|
|
173
|
+
if left.unit != right.unit or expression.unit != left.unit:
|
|
174
|
+
raise CapabilityError("arithmetic requires explicit matching units")
|
|
175
|
+
if isinstance(operand, IntegerType) and isinstance(dtype, IntegerType):
|
|
176
|
+
if expression.operation == "divide":
|
|
177
|
+
raise CapabilityError("division requires Decimal operands and output")
|
|
178
|
+
elif (
|
|
179
|
+
isinstance(operand, DecimalType)
|
|
180
|
+
and isinstance(right.logical_type, DecimalType)
|
|
181
|
+
and isinstance(dtype, DecimalType)
|
|
182
|
+
):
|
|
183
|
+
if expression.operation in ("add", "subtract") and (
|
|
184
|
+
operand.precision > 37
|
|
185
|
+
or dtype.scale != operand.scale
|
|
186
|
+
or dtype.precision < operand.precision + 1
|
|
187
|
+
):
|
|
188
|
+
raise CapabilityError(
|
|
189
|
+
"decimal add/subtract requires same scale and one guard digit"
|
|
190
|
+
)
|
|
191
|
+
if expression.operation == "multiply" and (
|
|
192
|
+
dtype.scale != operand.scale + right.logical_type.scale
|
|
193
|
+
or operand.precision > 38
|
|
194
|
+
or right.logical_type.precision > 38
|
|
195
|
+
):
|
|
196
|
+
raise CapabilityError(
|
|
197
|
+
"exact Decimal multiply requires operands precision<=38 and summed scales"
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
raise CapabilityError("arithmetic supports integer or exact Decimal operands")
|
|
201
|
+
return IRColumn(
|
|
202
|
+
name="arithmetic",
|
|
203
|
+
logical_type=dtype,
|
|
204
|
+
nullable=left.nullable or right.nullable,
|
|
205
|
+
unit=expression.unit,
|
|
206
|
+
)
|
|
207
|
+
if isinstance(expression, DecimalRound):
|
|
208
|
+
if expression.logical_type.precision > 38:
|
|
209
|
+
raise CapabilityError("native DecimalRound supports precision<=38")
|
|
210
|
+
argument = expression_type(expression.argument, columns, depth=depth + 1)
|
|
211
|
+
if not isinstance(argument.logical_type, DecimalType):
|
|
212
|
+
raise CapabilityError("DecimalRound requires a Decimal argument")
|
|
213
|
+
if expression.logical_type.scale > argument.logical_type.scale:
|
|
214
|
+
raise CapabilityError("DecimalRound output scale cannot exceed input scale")
|
|
215
|
+
return argument.model_copy(
|
|
216
|
+
update={"name": "decimal_round", "logical_type": expression.logical_type}
|
|
217
|
+
)
|
|
218
|
+
if isinstance(expression, LocalizeTimestamp):
|
|
219
|
+
argument = expression_type(expression.argument, columns, depth=depth + 1)
|
|
220
|
+
dtype = argument.logical_type
|
|
221
|
+
if not isinstance(dtype, TimestampType) or dtype.timezone is not None:
|
|
222
|
+
raise CapabilityError(
|
|
223
|
+
"LocalizeTimestamp requires naive timestamps; zone conversion is not localization"
|
|
224
|
+
)
|
|
225
|
+
return argument.model_copy(
|
|
226
|
+
update={
|
|
227
|
+
"name": "localize_timestamp",
|
|
228
|
+
"logical_type": TimestampType(unit=dtype.unit, timezone=expression.timezone),
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
if isinstance(expression, Coalesce):
|
|
232
|
+
if any(contains_arithmetic(item) for item in expression.arguments):
|
|
233
|
+
raise CapabilityError(
|
|
234
|
+
"fallible arithmetic inside coalesce requires a preceding checkpoint"
|
|
235
|
+
)
|
|
236
|
+
types = tuple(
|
|
237
|
+
expression_type(item, columns, depth=depth + 1) for item in expression.arguments
|
|
238
|
+
)
|
|
239
|
+
first = types[0]
|
|
240
|
+
if any(c.logical_type != first.logical_type or c.unit != first.unit for c in types):
|
|
241
|
+
raise ValueError("coalesce requires identical types and units")
|
|
242
|
+
return first.model_copy(
|
|
243
|
+
update={"name": "coalesce", "nullable": all(c.nullable for c in types)}
|
|
244
|
+
)
|
|
245
|
+
if isinstance(expression, Conditional):
|
|
246
|
+
if any(
|
|
247
|
+
contains_arithmetic(item)
|
|
248
|
+
for item in (expression.predicate, expression.then, expression.otherwise)
|
|
249
|
+
):
|
|
250
|
+
raise CapabilityError(
|
|
251
|
+
"fallible arithmetic inside conditional requires a preceding checkpoint"
|
|
252
|
+
)
|
|
253
|
+
predicate = expression_type(expression.predicate, columns, depth=depth + 1)
|
|
254
|
+
left = expression_type(expression.then, columns, depth=depth + 1)
|
|
255
|
+
right = expression_type(expression.otherwise, columns, depth=depth + 1)
|
|
256
|
+
if not isinstance(predicate.logical_type, BooleanType):
|
|
257
|
+
raise ValueError("conditional predicate requires boolean")
|
|
258
|
+
if left.logical_type != right.logical_type or left.unit != right.unit:
|
|
259
|
+
raise ValueError("conditional branches require identical types and units")
|
|
260
|
+
return left.model_copy(
|
|
261
|
+
update={"name": "conditional", "nullable": left.nullable or right.nullable}
|
|
262
|
+
)
|
|
263
|
+
if isinstance(expression, Compare):
|
|
264
|
+
left = expression_type(expression.left, columns, depth=depth + 1)
|
|
265
|
+
right = expression_type(expression.right, columns, depth=depth + 1)
|
|
266
|
+
if left.logical_type != right.logical_type or (
|
|
267
|
+
left.unit is not None and right.unit is not None and left.unit != right.unit
|
|
268
|
+
):
|
|
269
|
+
raise ValueError("comparison requires identical types and compatible units")
|
|
270
|
+
return IRColumn(
|
|
271
|
+
name="predicate", logical_type=BooleanType(), nullable=left.nullable or right.nullable
|
|
272
|
+
)
|
|
273
|
+
if isinstance(expression, (Boolean, Not)):
|
|
274
|
+
if contains_arithmetic(expression):
|
|
275
|
+
raise CapabilityError(
|
|
276
|
+
"fallible arithmetic inside boolean logic requires a preceding checkpoint"
|
|
277
|
+
)
|
|
278
|
+
arguments = (
|
|
279
|
+
expression.arguments if isinstance(expression, Boolean) else (expression.argument,)
|
|
280
|
+
)
|
|
281
|
+
types = tuple(expression_type(argument, columns, depth=depth + 1) for argument in arguments)
|
|
282
|
+
if any(not isinstance(column.logical_type, BooleanType) for column in types):
|
|
283
|
+
raise ValueError("boolean operators require boolean expressions")
|
|
284
|
+
return IRColumn(
|
|
285
|
+
name="predicate",
|
|
286
|
+
logical_type=BooleanType(),
|
|
287
|
+
nullable=any(column.nullable for column in types),
|
|
288
|
+
)
|
|
289
|
+
argument = expression_type(expression.argument, columns, depth=depth + 1)
|
|
290
|
+
if isinstance(expression, IsNaN) and not isinstance(argument.logical_type, FloatType):
|
|
291
|
+
raise ValueError("is_nan requires float")
|
|
292
|
+
return IRColumn(name="predicate", logical_type=BooleanType(), nullable=False)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def contains_arithmetic(expression: Expression) -> bool:
|
|
296
|
+
if isinstance(expression, (Arithmetic, DecimalRound, LocalizeTimestamp)):
|
|
297
|
+
return True
|
|
298
|
+
if isinstance(expression, (Column, LiteralExpression)):
|
|
299
|
+
return False
|
|
300
|
+
if isinstance(expression, Compare):
|
|
301
|
+
return contains_arithmetic(expression.left) or contains_arithmetic(expression.right)
|
|
302
|
+
if isinstance(expression, (Boolean, Coalesce)):
|
|
303
|
+
return any(contains_arithmetic(item) for item in expression.arguments)
|
|
304
|
+
if isinstance(expression, Conditional):
|
|
305
|
+
return any(
|
|
306
|
+
contains_arithmetic(item)
|
|
307
|
+
for item in (expression.predicate, expression.then, expression.otherwise)
|
|
308
|
+
)
|
|
309
|
+
return contains_arithmetic(expression.argument)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def references(node: Node) -> tuple[str, ...]:
|
|
313
|
+
if isinstance(node, Scan):
|
|
314
|
+
return ()
|
|
315
|
+
if isinstance(node, EquiJoin):
|
|
316
|
+
return node.left, node.right
|
|
317
|
+
if isinstance(node, Concatenate):
|
|
318
|
+
return node.sources
|
|
319
|
+
return (node.source,)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _widen(source: LogicalType, target: LogicalType) -> bool:
|
|
323
|
+
if source == target:
|
|
324
|
+
return True
|
|
325
|
+
if isinstance(source, IntegerType) and isinstance(target, IntegerType):
|
|
326
|
+
lower, upper = integer_bounds(source)
|
|
327
|
+
next_lower, next_upper = integer_bounds(target)
|
|
328
|
+
return next_lower <= lower and next_upper >= upper
|
|
329
|
+
return (
|
|
330
|
+
isinstance(source, DecimalType)
|
|
331
|
+
and isinstance(target, DecimalType)
|
|
332
|
+
and target.scale == source.scale
|
|
333
|
+
and target.precision >= source.precision
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def validate_ir(plan: RelationalPlan, contract: Contract) -> ValidatedIR:
|
|
338
|
+
plan = RelationalPlan.model_validate(plan)
|
|
339
|
+
contract = Contract.model_validate(contract)
|
|
340
|
+
schemas: dict[str, tuple[IRColumn, ...]] = {}
|
|
341
|
+
depths: dict[str, int] = {}
|
|
342
|
+
sources: set[str] = set()
|
|
343
|
+
inputs = {table.name: table.schema_spec.columns for table in contract.inputs}
|
|
344
|
+
for node in plan.nodes:
|
|
345
|
+
if node.node_id in schemas:
|
|
346
|
+
raise ValueError("duplicate IR node")
|
|
347
|
+
prior = references(node)
|
|
348
|
+
if any(ref not in schemas for ref in prior):
|
|
349
|
+
raise ValueError(
|
|
350
|
+
"IR references require preceding nodes; cycles/forward references are rejected"
|
|
351
|
+
)
|
|
352
|
+
depths[node.node_id] = 1 + max((depths[ref] for ref in prior), default=0)
|
|
353
|
+
if depths[node.node_id] > 32:
|
|
354
|
+
raise ValueError("IR node depth exceeds32")
|
|
355
|
+
if isinstance(node, Scan):
|
|
356
|
+
if node.table not in inputs:
|
|
357
|
+
raise ValueError("Scan requires a declared input table")
|
|
358
|
+
sources.add(node.table)
|
|
359
|
+
columns = tuple(
|
|
360
|
+
IRColumn(
|
|
361
|
+
name=c.name,
|
|
362
|
+
logical_type=c.logical_type,
|
|
363
|
+
nullable=c.nullable,
|
|
364
|
+
unit=c.numeric_policy.unit if c.numeric_policy else None,
|
|
365
|
+
)
|
|
366
|
+
for c in inputs[node.table]
|
|
367
|
+
)
|
|
368
|
+
elif isinstance(node, Project):
|
|
369
|
+
columns = tuple(
|
|
370
|
+
expression_type(item.expression, schemas[node.source]).model_copy(
|
|
371
|
+
update={"name": item.name}
|
|
372
|
+
)
|
|
373
|
+
for item in node.columns
|
|
374
|
+
)
|
|
375
|
+
elif isinstance(node, Filter):
|
|
376
|
+
columns = schemas[node.source]
|
|
377
|
+
if not isinstance(expression_type(node.predicate, columns).logical_type, BooleanType):
|
|
378
|
+
raise ValueError("Filter requires a boolean predicate")
|
|
379
|
+
elif isinstance(node, Cast):
|
|
380
|
+
_unique(tuple(c.column for c in node.columns))
|
|
381
|
+
replacements = {c.column: c.logical_type for c in node.columns}
|
|
382
|
+
for name, dtype in replacements.items():
|
|
383
|
+
if not _widen(_column(name, schemas[node.source]).logical_type, dtype):
|
|
384
|
+
raise CapabilityError(
|
|
385
|
+
"Cast only supports identity or exact integer/decimal widening"
|
|
386
|
+
)
|
|
387
|
+
columns = tuple(
|
|
388
|
+
c.model_copy(update={"logical_type": replacements.get(c.name, c.logical_type)})
|
|
389
|
+
for c in schemas[node.source]
|
|
390
|
+
)
|
|
391
|
+
elif isinstance(node, EquiJoin):
|
|
392
|
+
_unique(tuple(k.left for k in node.keys))
|
|
393
|
+
_unique(tuple(k.right for k in node.keys))
|
|
394
|
+
for key in node.keys:
|
|
395
|
+
left, right = (
|
|
396
|
+
_column(key.left, schemas[node.left]),
|
|
397
|
+
_column(key.right, schemas[node.right]),
|
|
398
|
+
)
|
|
399
|
+
if left.logical_type != right.logical_type or left.unit != right.unit:
|
|
400
|
+
raise ValueError("join keys must have identical types and units")
|
|
401
|
+
if isinstance(left.logical_type, FloatType):
|
|
402
|
+
raise CapabilityError("float join keys are not supported")
|
|
403
|
+
columns = tuple(
|
|
404
|
+
c.model_copy(update={"nullable": c.nullable or node.how in ("right", "full")})
|
|
405
|
+
for c in schemas[node.left]
|
|
406
|
+
) + (
|
|
407
|
+
()
|
|
408
|
+
if node.how in ("semi", "anti")
|
|
409
|
+
else tuple(
|
|
410
|
+
c.model_copy(
|
|
411
|
+
update={
|
|
412
|
+
"name": node.right_prefix + c.name,
|
|
413
|
+
"nullable": c.nullable or node.how in ("left", "full"),
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
for c in schemas[node.right]
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
elif isinstance(node, Concatenate):
|
|
420
|
+
columns = schemas[node.sources[0]]
|
|
421
|
+
if any(schemas[ref] != columns for ref in node.sources[1:]):
|
|
422
|
+
raise ValueError(
|
|
423
|
+
"concatenate requires identical ordered schemas/types/units/nullability"
|
|
424
|
+
)
|
|
425
|
+
elif isinstance(node, Deduplicate):
|
|
426
|
+
columns = schemas[node.source]
|
|
427
|
+
_unique(node.keys)
|
|
428
|
+
if node.keep == "identical" and set(node.keys) != {c.name for c in columns}:
|
|
429
|
+
raise CapabilityError("deduplicate identical requires every column as key")
|
|
430
|
+
_unique(tuple(key.column for key in node.by))
|
|
431
|
+
for name in (*node.keys, *(key.column for key in node.by)):
|
|
432
|
+
if isinstance(_column(name, columns).logical_type, FloatType):
|
|
433
|
+
raise CapabilityError("float deduplicate keys/tiebreak remain unsupported")
|
|
434
|
+
elif isinstance(node, Window):
|
|
435
|
+
source_columns = schemas[node.source]
|
|
436
|
+
_unique(node.partition_by)
|
|
437
|
+
_unique(tuple(key.column for key in node.by))
|
|
438
|
+
for name in (*node.partition_by, *(key.column for key in node.by)):
|
|
439
|
+
if isinstance(_column(name, source_columns).logical_type, FloatType):
|
|
440
|
+
raise CapabilityError("float window partition/order keys are unsupported")
|
|
441
|
+
measures = []
|
|
442
|
+
for window_measure in node.measures:
|
|
443
|
+
if window_measure.operation == "row_number":
|
|
444
|
+
if window_measure.logical_type != IntegerType(bits=64, signed=True):
|
|
445
|
+
raise ValueError("row_number requires signed64 output")
|
|
446
|
+
column = IRColumn(
|
|
447
|
+
name=window_measure.name,
|
|
448
|
+
logical_type=window_measure.logical_type,
|
|
449
|
+
nullable=False,
|
|
450
|
+
unit=window_measure.unit,
|
|
451
|
+
)
|
|
452
|
+
else:
|
|
453
|
+
if window_measure.column is None:
|
|
454
|
+
raise ValueError("lag/lead require an input column")
|
|
455
|
+
window_source = _column(window_measure.column, source_columns)
|
|
456
|
+
if window_measure.logical_type != window_source.logical_type:
|
|
457
|
+
raise ValueError("lag/lead preserve the exact input type")
|
|
458
|
+
if (
|
|
459
|
+
window_measure.unit is not None
|
|
460
|
+
and window_measure.unit != window_source.unit
|
|
461
|
+
):
|
|
462
|
+
raise ValueError("window units must preserve source units")
|
|
463
|
+
column = window_source.model_copy(
|
|
464
|
+
update={"name": window_measure.name, "nullable": True}
|
|
465
|
+
)
|
|
466
|
+
measures.append(column)
|
|
467
|
+
columns = source_columns + tuple(measures)
|
|
468
|
+
elif isinstance(node, Aggregate):
|
|
469
|
+
_unique(node.group_by)
|
|
470
|
+
groups = tuple(_column(name, schemas[node.source]) for name in node.group_by)
|
|
471
|
+
if any(isinstance(c.logical_type, FloatType) for c in groups):
|
|
472
|
+
raise CapabilityError("float group keys are not supported")
|
|
473
|
+
measures = []
|
|
474
|
+
for measure in node.measures:
|
|
475
|
+
source = _column(measure.column, schemas[node.source]) if measure.column else None
|
|
476
|
+
dtype = measure.logical_type
|
|
477
|
+
count = measure.operation in ("count_rows", "count_valid")
|
|
478
|
+
if count:
|
|
479
|
+
if dtype != IntegerType(bits=64, signed=True):
|
|
480
|
+
raise ValueError("counts require signed64 output")
|
|
481
|
+
elif source is None or not isinstance(
|
|
482
|
+
source.logical_type, (IntegerType, DecimalType)
|
|
483
|
+
):
|
|
484
|
+
raise CapabilityError("exact aggregates support integer/decimal only")
|
|
485
|
+
elif measure.operation == "sum":
|
|
486
|
+
if isinstance(source.logical_type, IntegerType):
|
|
487
|
+
if (
|
|
488
|
+
dtype != IntegerType(bits=64, signed=True)
|
|
489
|
+
or not source.logical_type.signed
|
|
490
|
+
):
|
|
491
|
+
raise CapabilityError(
|
|
492
|
+
"integer sums require signed input and signed64 output"
|
|
493
|
+
)
|
|
494
|
+
elif (
|
|
495
|
+
not isinstance(dtype, DecimalType)
|
|
496
|
+
or dtype.scale != source.logical_type.scale
|
|
497
|
+
or dtype.precision < source.logical_type.precision
|
|
498
|
+
):
|
|
499
|
+
raise ValueError("decimal sum must preserve scale and not narrow precision")
|
|
500
|
+
elif dtype != source.logical_type:
|
|
501
|
+
raise ValueError("min/max preserve input type")
|
|
502
|
+
if (
|
|
503
|
+
source
|
|
504
|
+
and not count
|
|
505
|
+
and measure.unit is not None
|
|
506
|
+
and measure.unit != source.unit
|
|
507
|
+
):
|
|
508
|
+
raise ValueError("aggregate units must match source units")
|
|
509
|
+
measures.append(
|
|
510
|
+
IRColumn(
|
|
511
|
+
name=measure.name,
|
|
512
|
+
logical_type=dtype,
|
|
513
|
+
nullable=not count
|
|
514
|
+
and (measure.operation != "sum" or node.empty_sum == "null"),
|
|
515
|
+
unit=source.unit if source and not count else measure.unit,
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
columns = groups + tuple(measures)
|
|
519
|
+
else:
|
|
520
|
+
columns = schemas[node.source]
|
|
521
|
+
_unique(tuple(key.column for key in node.by))
|
|
522
|
+
for sort_key in node.by:
|
|
523
|
+
if isinstance(_column(sort_key.column, columns).logical_type, FloatType):
|
|
524
|
+
raise CapabilityError("float sort keys are not supported")
|
|
525
|
+
if node.ties == "identical_rows" and set(key.column for key in node.by) != {
|
|
526
|
+
c.name for c in columns
|
|
527
|
+
}:
|
|
528
|
+
raise CapabilityError("identical_rows ties require sorting every output column")
|
|
529
|
+
_unique(tuple(column.name for column in columns))
|
|
530
|
+
if len(columns) > 256:
|
|
531
|
+
raise ValueError("IR schema exceeds256 columns")
|
|
532
|
+
if any(
|
|
533
|
+
isinstance(c.logical_type, DecimalType) and c.logical_type.precision > 38
|
|
534
|
+
for c in columns
|
|
535
|
+
):
|
|
536
|
+
raise CapabilityError("native common IR supports decimal precision<=38")
|
|
537
|
+
schemas[node.node_id] = columns
|
|
538
|
+
if plan.output not in schemas:
|
|
539
|
+
raise ValueError("IR output node does not exist")
|
|
540
|
+
reachable = {plan.output}
|
|
541
|
+
for node in reversed(plan.nodes):
|
|
542
|
+
if node.node_id in reachable:
|
|
543
|
+
reachable.update(references(node))
|
|
544
|
+
if reachable != set(schemas):
|
|
545
|
+
raise ValueError("IR contains unused nodes")
|
|
546
|
+
if sources != set(inputs):
|
|
547
|
+
raise ValueError("IR Scans must cover exactly the contract inputs")
|
|
548
|
+
expected, actual = contract.output.schema_spec.columns, schemas[plan.output]
|
|
549
|
+
order = contract.output.schema_spec.order
|
|
550
|
+
if isinstance(order, SignificantOrder):
|
|
551
|
+
from .types import Sort
|
|
552
|
+
|
|
553
|
+
final = next(node for node in plan.nodes if node.node_id == plan.output)
|
|
554
|
+
matching_ties = isinstance(final, Sort) and (
|
|
555
|
+
(final.ties == "reject" and order.ties == "reject")
|
|
556
|
+
or (final.ties == "identical_rows" and order.ties == "preserve")
|
|
557
|
+
)
|
|
558
|
+
if (
|
|
559
|
+
not isinstance(final, Sort)
|
|
560
|
+
or tuple((k.column, k.direction, k.nulls) for k in final.by)
|
|
561
|
+
!= tuple((k.column, k.direction, k.nulls) for k in order.by)
|
|
562
|
+
or not matching_ties
|
|
563
|
+
):
|
|
564
|
+
raise CapabilityError("significant output order requires a matching final Sort")
|
|
565
|
+
if tuple(c.name for c in expected) != tuple(c.name for c in actual):
|
|
566
|
+
raise ValueError("IR output names/order differ from contract")
|
|
567
|
+
for observed, wanted in zip(actual, expected, strict=True):
|
|
568
|
+
if (
|
|
569
|
+
observed.logical_type != wanted.logical_type
|
|
570
|
+
or (observed.nullable and not wanted.nullable)
|
|
571
|
+
or observed.unit != (wanted.numeric_policy.unit if wanted.numeric_policy else None)
|
|
572
|
+
):
|
|
573
|
+
raise ValueError("IR output type/nullability/unit differs from contract")
|
|
574
|
+
return ValidatedIR(
|
|
575
|
+
plan,
|
|
576
|
+
contract,
|
|
577
|
+
tuple(NodeSchema(node_id=n.node_id, columns=schemas[n.node_id]) for n in plan.nodes),
|
|
578
|
+
tuple(sorted(sources)),
|
|
579
|
+
actual,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def compilation_identity(
|
|
584
|
+
validated: ValidatedIR, engine: str, *, implementation_version: str | None = None
|
|
585
|
+
) -> tuple[str, str, str]:
|
|
586
|
+
ir_sha256 = ir_fingerprint(validated.plan)
|
|
587
|
+
contract_sha256 = contract_fingerprint(validated.contract)
|
|
588
|
+
identity = implementation_version or f"migration-native-ir1.0:{engine}"
|
|
589
|
+
fingerprint = hashlib.sha256(f"{identity}:{ir_sha256}:{contract_sha256}".encode()).hexdigest()
|
|
590
|
+
return "migration_" + fingerprint, ir_sha256, contract_sha256
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def literal_scalar(expression: LiteralExpression) -> bool | int | float | str | Decimal | None:
|
|
594
|
+
value = expression.value
|
|
595
|
+
if isinstance(value, NullValue):
|
|
596
|
+
return None
|
|
597
|
+
if isinstance(value, NaNValue):
|
|
598
|
+
return float("nan")
|
|
599
|
+
if isinstance(value, (BooleanValue, IntegerValue, FloatValue, DecimalValue, StringValue)):
|
|
600
|
+
return value.value
|
|
601
|
+
raise CapabilityError("unsupported IR literal")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def iter_expressions(expression: Expression) -> Iterator[Expression]:
|
|
605
|
+
"""Walk a closed expression without importing any optional runtime."""
|
|
606
|
+
yield expression
|
|
607
|
+
if isinstance(expression, (Column, LiteralExpression)):
|
|
608
|
+
return
|
|
609
|
+
children: tuple[Expression, ...]
|
|
610
|
+
if isinstance(expression, (Arithmetic, Compare)):
|
|
611
|
+
children = (expression.left, expression.right)
|
|
612
|
+
elif isinstance(expression, (Boolean, Coalesce)):
|
|
613
|
+
children = expression.arguments
|
|
614
|
+
elif isinstance(expression, Conditional):
|
|
615
|
+
children = (expression.predicate, expression.then, expression.otherwise)
|
|
616
|
+
else:
|
|
617
|
+
children = (expression.argument,)
|
|
618
|
+
for child in children:
|
|
619
|
+
yield from iter_expressions(child)
|