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
equiframe/contracts.py
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
"""Strict declarative semantic contracts. No table processing or rule execution."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from datetime import date
|
|
5
|
+
from datetime import timezone as _timezone
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BeforeValidator, Field, ValidationError, model_validator
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from equiframe._base import (
|
|
12
|
+
FrozenModel,
|
|
13
|
+
Identifier,
|
|
14
|
+
NonNegativeInt,
|
|
15
|
+
PositiveInt,
|
|
16
|
+
Text,
|
|
17
|
+
fail,
|
|
18
|
+
strict_int_literal,
|
|
19
|
+
unique,
|
|
20
|
+
)
|
|
21
|
+
from equiframe._timezones import timezone
|
|
22
|
+
from equiframe.values import (
|
|
23
|
+
DateValue,
|
|
24
|
+
ExactDecimal,
|
|
25
|
+
ScalarValue,
|
|
26
|
+
TimestampValue,
|
|
27
|
+
TimeUnit,
|
|
28
|
+
TimeZone,
|
|
29
|
+
_timestamp_datetime,
|
|
30
|
+
offset_nanoseconds,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
UTC = _timezone.utc
|
|
34
|
+
|
|
35
|
+
SCHEMA_VERSION = "1.0"
|
|
36
|
+
Family = Literal["nulls_types", "joins", "duplicates_order", "aggregations", "dates"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BooleanType(FrozenModel):
|
|
40
|
+
kind: Literal["boolean"] = "boolean"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IntegerType(FrozenModel):
|
|
44
|
+
kind: Literal["integer"] = "integer"
|
|
45
|
+
bits: Annotated[Literal[8, 16, 32, 64], BeforeValidator(strict_int_literal)]
|
|
46
|
+
signed: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StringType(FrozenModel):
|
|
50
|
+
kind: Literal["string"] = "string"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DecimalType(FrozenModel):
|
|
54
|
+
kind: Literal["decimal"] = "decimal"
|
|
55
|
+
precision: Annotated[int, Field(ge=1, le=76)]
|
|
56
|
+
scale: NonNegativeInt
|
|
57
|
+
|
|
58
|
+
@model_validator(mode="after")
|
|
59
|
+
def valid_scale(self) -> Self:
|
|
60
|
+
if self.scale > self.precision:
|
|
61
|
+
fail("decimal_scale", "scale must not exceed precision", "scale")
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FloatType(FrozenModel):
|
|
66
|
+
kind: Literal["float"] = "float"
|
|
67
|
+
bits: Annotated[Literal[32, 64], BeforeValidator(strict_int_literal)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DateType(FrozenModel):
|
|
71
|
+
kind: Literal["date"] = "date"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TimestampType(FrozenModel):
|
|
75
|
+
kind: Literal["timestamp"] = "timestamp"
|
|
76
|
+
unit: TimeUnit
|
|
77
|
+
timezone: TimeZone | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
LogicalType = Annotated[
|
|
81
|
+
BooleanType | IntegerType | StringType | DecimalType | FloatType | DateType | TimestampType,
|
|
82
|
+
Field(discriminator="kind"),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class NullPolicy(FrozenModel):
|
|
87
|
+
representations: Annotated[
|
|
88
|
+
tuple[Literal["null", "empty_string", "zero"], ...], Field(min_length=1)
|
|
89
|
+
]
|
|
90
|
+
justification: Text | None = None
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def explicit_equivalences(self) -> Self:
|
|
94
|
+
unique(self.representations, "representations")
|
|
95
|
+
if "null" not in self.representations:
|
|
96
|
+
fail("missing_null", "logical absence must include null", "representations")
|
|
97
|
+
if len(self.representations) > 1 and self.justification is None:
|
|
98
|
+
fail(
|
|
99
|
+
"null_justification", "absence equivalences require justification", "justification"
|
|
100
|
+
)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NaNPolicy(FrozenModel):
|
|
105
|
+
handling: Literal["forbid", "distinct", "as_null"]
|
|
106
|
+
justification: Text | None = None
|
|
107
|
+
|
|
108
|
+
@model_validator(mode="after")
|
|
109
|
+
def explicit_equivalence(self) -> Self:
|
|
110
|
+
if self.handling == "as_null" and self.justification is None:
|
|
111
|
+
fail("nan_justification", "NaN as absence requires justification", "justification")
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Tolerance(FrozenModel):
|
|
116
|
+
absolute: ExactDecimal | None = None
|
|
117
|
+
relative: ExactDecimal | None = None
|
|
118
|
+
unit: Text
|
|
119
|
+
justification: Text
|
|
120
|
+
|
|
121
|
+
@model_validator(mode="after")
|
|
122
|
+
def nonnegative_bounds(self) -> Self:
|
|
123
|
+
if self.absolute is None and self.relative is None:
|
|
124
|
+
fail("empty_tolerance", "declare absolute or relative tolerance", "absolute")
|
|
125
|
+
for field, value in (("absolute", self.absolute), ("relative", self.relative)):
|
|
126
|
+
if value is not None and value < 0:
|
|
127
|
+
fail("negative_tolerance", "tolerance must be nonnegative", field)
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class NumericPolicy(FrozenModel):
|
|
132
|
+
comparison: Literal["exact", "approximate"]
|
|
133
|
+
unit: Text
|
|
134
|
+
rounding: (
|
|
135
|
+
Literal[
|
|
136
|
+
"ROUND_DOWN",
|
|
137
|
+
"ROUND_UP",
|
|
138
|
+
"ROUND_CEILING",
|
|
139
|
+
"ROUND_FLOOR",
|
|
140
|
+
"ROUND_HALF_UP",
|
|
141
|
+
"ROUND_HALF_DOWN",
|
|
142
|
+
"ROUND_HALF_EVEN",
|
|
143
|
+
"ROUND_05UP",
|
|
144
|
+
]
|
|
145
|
+
| None
|
|
146
|
+
) = None
|
|
147
|
+
rounding_justification: Text | None = None
|
|
148
|
+
tolerance: Tolerance | None = None
|
|
149
|
+
|
|
150
|
+
@model_validator(mode="after")
|
|
151
|
+
def consistent_comparison(self) -> Self:
|
|
152
|
+
if self.comparison == "exact" and self.tolerance is not None:
|
|
153
|
+
fail("exact_tolerance", "exact comparison cannot have a tolerance", "tolerance")
|
|
154
|
+
if self.comparison == "approximate" and self.tolerance is None:
|
|
155
|
+
fail("missing_tolerance", "approximate comparison requires tolerance", "tolerance")
|
|
156
|
+
if self.tolerance is not None and self.tolerance.unit != self.unit:
|
|
157
|
+
fail("tolerance_unit", "tolerance unit must match the column unit", "tolerance", "unit")
|
|
158
|
+
if (self.rounding is None) != (self.rounding_justification is None):
|
|
159
|
+
fail(
|
|
160
|
+
"rounding_justification",
|
|
161
|
+
"rounding requires an explicit mode and justification",
|
|
162
|
+
"rounding",
|
|
163
|
+
)
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
IntervalClosure = Literal["both", "left", "right", "neither"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DatePolicy(FrozenModel):
|
|
171
|
+
kind: Literal["date"] = "date"
|
|
172
|
+
granularity: Literal["day"] = "day"
|
|
173
|
+
lower: DateValue | None = None
|
|
174
|
+
upper: DateValue | None = None
|
|
175
|
+
closed: IntervalClosure
|
|
176
|
+
|
|
177
|
+
@model_validator(mode="after")
|
|
178
|
+
def ordered_interval(self) -> Self:
|
|
179
|
+
if self.lower is not None and self.upper is not None:
|
|
180
|
+
if self.lower.value > self.upper.value:
|
|
181
|
+
fail("temporal_interval", "lower bound exceeds upper bound", "upper")
|
|
182
|
+
if self.lower.value == self.upper.value and self.closed != "both":
|
|
183
|
+
fail("empty_interval", "equal bounds require both endpoints included", "closed")
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TimestampPolicy(FrozenModel):
|
|
188
|
+
kind: Literal["timestamp"] = "timestamp"
|
|
189
|
+
granularity: TimeUnit
|
|
190
|
+
timezone: TimeZone | None
|
|
191
|
+
naive: Literal["reject", "preserve", "assume_zone"]
|
|
192
|
+
comparison: Literal["instant", "wall_time"]
|
|
193
|
+
ambiguous: Literal["reject", "earlier", "later"]
|
|
194
|
+
nonexistent: Literal["reject"] = "reject"
|
|
195
|
+
lower: TimestampValue | None = None
|
|
196
|
+
upper: TimestampValue | None = None
|
|
197
|
+
closed: IntervalClosure
|
|
198
|
+
|
|
199
|
+
def _bound(self, value: TimestampValue, field: str) -> int:
|
|
200
|
+
max_digits = {"s": 0, "ms": 3, "us": 6, "ns": 9}[self.granularity]
|
|
201
|
+
if value.fractional_digits > max_digits:
|
|
202
|
+
fail("temporal_precision", "bound exceeds declared granularity", field, "value")
|
|
203
|
+
if value.zone is not None and value.zone != self.timezone:
|
|
204
|
+
fail("temporal_zone", "bound zone must match policy zone", field, "zone")
|
|
205
|
+
if value.has_offset:
|
|
206
|
+
if self.timezone is not None:
|
|
207
|
+
stamp = _timestamp_datetime(value.value)
|
|
208
|
+
try:
|
|
209
|
+
actual_offset = stamp.astimezone(timezone(self.timezone)).utcoffset()
|
|
210
|
+
except (OverflowError, ValueError):
|
|
211
|
+
fail("timestamp_range", "zoned instant exceeds calendar range", field, "value")
|
|
212
|
+
if actual_offset != stamp.utcoffset():
|
|
213
|
+
fail(
|
|
214
|
+
"zone_offset_mismatch",
|
|
215
|
+
"bound offset disagrees with policy zone",
|
|
216
|
+
field,
|
|
217
|
+
"value",
|
|
218
|
+
)
|
|
219
|
+
return value.nanoseconds(instant=self.comparison == "instant")
|
|
220
|
+
if self.naive == "reject":
|
|
221
|
+
fail("naive_timestamp", "policy rejects timestamps without offset", field, "value")
|
|
222
|
+
if self.naive == "preserve":
|
|
223
|
+
return value.nanoseconds(instant=False)
|
|
224
|
+
if not (self.timezone is not None):
|
|
225
|
+
raise AssertionError("Internal invariant check failed.")
|
|
226
|
+
choices = [value.in_zone(self.timezone, fold=fold) for fold in (0, 1)]
|
|
227
|
+
try:
|
|
228
|
+
valid = [
|
|
229
|
+
choice
|
|
230
|
+
for choice in choices
|
|
231
|
+
if choice.astimezone(UTC).astimezone(choice.tzinfo).replace(tzinfo=None)
|
|
232
|
+
== choice.replace(tzinfo=None)
|
|
233
|
+
]
|
|
234
|
+
except (OverflowError, ValueError):
|
|
235
|
+
fail("timestamp_range", "zoned instant exceeds calendar range", field, "value")
|
|
236
|
+
if not valid:
|
|
237
|
+
fail("nonexistent_time", "local timestamp falls in a DST gap", field, "value")
|
|
238
|
+
offsets = {offset_nanoseconds(choice.utcoffset()) for choice in valid}
|
|
239
|
+
if len(offsets) > 1 and self.ambiguous == "reject":
|
|
240
|
+
fail("ambiguous_time", "local timestamp has two possible UTC offsets", field, "value")
|
|
241
|
+
result = value.nanoseconds(instant=False)
|
|
242
|
+
if self.comparison == "wall_time":
|
|
243
|
+
return result
|
|
244
|
+
instants = [result - offset for offset in offsets]
|
|
245
|
+
return max(instants) if self.ambiguous == "later" else min(instants)
|
|
246
|
+
|
|
247
|
+
@model_validator(mode="after")
|
|
248
|
+
def consistent_time(self) -> Self:
|
|
249
|
+
if self.naive == "assume_zone" and self.timezone is None:
|
|
250
|
+
fail("missing_timezone", "assume_zone requires an IANA time zone", "timezone")
|
|
251
|
+
if self.naive == "preserve" and (
|
|
252
|
+
self.comparison != "wall_time" or self.timezone is not None
|
|
253
|
+
):
|
|
254
|
+
fail("naive_policy", "preserved naive time requires wall_time and no zone", "naive")
|
|
255
|
+
if self.naive != "assume_zone" and self.ambiguous != "reject":
|
|
256
|
+
fail("ambiguous_policy", "DST resolution applies only to assume_zone", "ambiguous")
|
|
257
|
+
lower = self._bound(self.lower, "lower") if self.lower is not None else None
|
|
258
|
+
upper = self._bound(self.upper, "upper") if self.upper is not None else None
|
|
259
|
+
if (
|
|
260
|
+
self.naive == "preserve"
|
|
261
|
+
and self.lower is not None
|
|
262
|
+
and self.upper is not None
|
|
263
|
+
and self.lower.has_offset != self.upper.has_offset
|
|
264
|
+
):
|
|
265
|
+
fail(
|
|
266
|
+
"mixed_temporal_bounds",
|
|
267
|
+
"bounds must both be naive or both have offsets",
|
|
268
|
+
"upper",
|
|
269
|
+
)
|
|
270
|
+
if lower is not None and upper is not None:
|
|
271
|
+
if lower > upper:
|
|
272
|
+
fail("temporal_interval", "lower bound exceeds upper bound", "upper")
|
|
273
|
+
if lower == upper and self.closed != "both":
|
|
274
|
+
fail("empty_interval", "equal bounds require both endpoints included", "closed")
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
TemporalPolicy = Annotated[DatePolicy | TimestampPolicy, Field(discriminator="kind")]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class ColumnSpec(FrozenModel):
|
|
282
|
+
name: Text
|
|
283
|
+
logical_type: LogicalType
|
|
284
|
+
nullable: bool
|
|
285
|
+
null_policy: NullPolicy
|
|
286
|
+
nan_policy: NaNPolicy | None = None
|
|
287
|
+
numeric_policy: NumericPolicy | None = None
|
|
288
|
+
temporal_policy: TemporalPolicy | None = None
|
|
289
|
+
allowed_values: tuple[ScalarValue, ...] = ()
|
|
290
|
+
|
|
291
|
+
@model_validator(mode="after")
|
|
292
|
+
def compatible_policies(self) -> Self:
|
|
293
|
+
kind = self.logical_type.kind
|
|
294
|
+
representations = self.null_policy.representations
|
|
295
|
+
if len(representations) > 1 and not self.nullable:
|
|
296
|
+
fail(
|
|
297
|
+
"nonnullable_equivalence",
|
|
298
|
+
"absence equivalences require a nullable column",
|
|
299
|
+
"null_policy",
|
|
300
|
+
)
|
|
301
|
+
if "empty_string" in representations and kind != "string":
|
|
302
|
+
fail("null_type", "empty_string absence is only valid for strings", "null_policy")
|
|
303
|
+
if "zero" in representations and kind not in ("integer", "decimal", "float"):
|
|
304
|
+
fail("null_type", "zero absence is only valid for numeric columns", "null_policy")
|
|
305
|
+
if kind == "float":
|
|
306
|
+
if self.nan_policy is None:
|
|
307
|
+
fail("missing_nan_policy", "float requires an explicit NaN policy", "nan_policy")
|
|
308
|
+
if self.nan_policy.handling == "as_null" and not self.nullable:
|
|
309
|
+
fail("nan_nullable", "NaN as absence requires a nullable column", "nan_policy")
|
|
310
|
+
elif self.nan_policy is not None:
|
|
311
|
+
fail("nan_type", "NaN policy is only valid for floats", "nan_policy")
|
|
312
|
+
if kind in ("integer", "decimal", "float"):
|
|
313
|
+
if self.numeric_policy is None:
|
|
314
|
+
fail(
|
|
315
|
+
"missing_numeric_policy",
|
|
316
|
+
"numeric columns require an explicit numeric policy",
|
|
317
|
+
"numeric_policy",
|
|
318
|
+
)
|
|
319
|
+
if self.numeric_policy.rounding is not None and kind != "decimal":
|
|
320
|
+
fail(
|
|
321
|
+
"rounding_type",
|
|
322
|
+
"decimal rounding is only valid for decimal columns",
|
|
323
|
+
"numeric_policy",
|
|
324
|
+
"rounding",
|
|
325
|
+
)
|
|
326
|
+
if kind == "decimal" and self.numeric_policy.comparison != "exact":
|
|
327
|
+
fail(
|
|
328
|
+
"decimal_exact",
|
|
329
|
+
"decimal columns require exact comparison",
|
|
330
|
+
"numeric_policy",
|
|
331
|
+
"comparison",
|
|
332
|
+
)
|
|
333
|
+
elif self.numeric_policy is not None:
|
|
334
|
+
fail("numeric_type", "numeric policy requires a numeric type", "numeric_policy")
|
|
335
|
+
if kind in ("date", "timestamp"):
|
|
336
|
+
if self.temporal_policy is None or self.temporal_policy.kind != kind:
|
|
337
|
+
fail(
|
|
338
|
+
"temporal_type",
|
|
339
|
+
"temporal policy must match the logical type",
|
|
340
|
+
"temporal_policy",
|
|
341
|
+
)
|
|
342
|
+
if isinstance(self.logical_type, TimestampType):
|
|
343
|
+
if not (isinstance(self.temporal_policy, TimestampPolicy)):
|
|
344
|
+
raise AssertionError("Internal invariant check failed.")
|
|
345
|
+
if self.logical_type.timezone != self.temporal_policy.timezone:
|
|
346
|
+
fail(
|
|
347
|
+
"temporal_zone",
|
|
348
|
+
"type and policy zones must match",
|
|
349
|
+
"temporal_policy",
|
|
350
|
+
"timezone",
|
|
351
|
+
)
|
|
352
|
+
if self.logical_type.unit != self.temporal_policy.granularity:
|
|
353
|
+
fail(
|
|
354
|
+
"temporal_unit",
|
|
355
|
+
"type and policy granularities must match",
|
|
356
|
+
"temporal_policy",
|
|
357
|
+
"granularity",
|
|
358
|
+
)
|
|
359
|
+
elif self.temporal_policy is not None:
|
|
360
|
+
fail("temporal_type", "temporal policy requires a date or timestamp", "temporal_policy")
|
|
361
|
+
for index, value in enumerate(self.allowed_values):
|
|
362
|
+
self._literal(value, index)
|
|
363
|
+
return self
|
|
364
|
+
|
|
365
|
+
def _literal(self, value: ScalarValue, index: int) -> None:
|
|
366
|
+
location: tuple[str | int, ...] = ("allowed_values", index)
|
|
367
|
+
kind = self.logical_type.kind
|
|
368
|
+
if value.kind == "null":
|
|
369
|
+
if not self.nullable:
|
|
370
|
+
fail("literal_null", "null is not allowed in a nonnullable column", *location)
|
|
371
|
+
return
|
|
372
|
+
if value.kind in ("nan", "infinity"):
|
|
373
|
+
if kind != "float":
|
|
374
|
+
fail("literal_type", "special float value requires a float column", *location)
|
|
375
|
+
if (
|
|
376
|
+
value.kind == "nan"
|
|
377
|
+
and self.nan_policy is not None
|
|
378
|
+
and self.nan_policy.handling == "forbid"
|
|
379
|
+
):
|
|
380
|
+
fail("literal_nan", "NaN is forbidden by the column policy", *location)
|
|
381
|
+
return
|
|
382
|
+
if value.kind != kind:
|
|
383
|
+
fail("literal_type", "literal kind must match column kind", *location)
|
|
384
|
+
if isinstance(self.logical_type, IntegerType) and value.kind == "integer":
|
|
385
|
+
bits = self.logical_type.bits
|
|
386
|
+
lower = -(2 ** (bits - 1)) if self.logical_type.signed else 0
|
|
387
|
+
upper = 2 ** (bits - (1 if self.logical_type.signed else 0)) - 1
|
|
388
|
+
if not lower <= value.value <= upper:
|
|
389
|
+
fail("integer_range", "literal exceeds integer width/sign", *location, "value")
|
|
390
|
+
if isinstance(self.logical_type, DecimalType) and value.kind == "decimal":
|
|
391
|
+
parts = value.value.as_tuple()
|
|
392
|
+
if not (isinstance(parts.exponent, int)):
|
|
393
|
+
raise AssertionError("Internal invariant check failed.")
|
|
394
|
+
if parts.exponent < -self.logical_type.scale:
|
|
395
|
+
fail(
|
|
396
|
+
"decimal_literal_scale",
|
|
397
|
+
"literal would lose fractional scale",
|
|
398
|
+
*location,
|
|
399
|
+
"value",
|
|
400
|
+
)
|
|
401
|
+
integer_digits = max(len(parts.digits) + parts.exponent, 0) if value.value != 0 else 0
|
|
402
|
+
if integer_digits > self.logical_type.precision - self.logical_type.scale:
|
|
403
|
+
fail(
|
|
404
|
+
"decimal_literal_precision",
|
|
405
|
+
"literal exceeds decimal precision",
|
|
406
|
+
*location,
|
|
407
|
+
"value",
|
|
408
|
+
)
|
|
409
|
+
if (
|
|
410
|
+
isinstance(self.logical_type, FloatType)
|
|
411
|
+
and value.kind == "float"
|
|
412
|
+
and self.logical_type.bits == 32
|
|
413
|
+
):
|
|
414
|
+
try:
|
|
415
|
+
represented = struct.unpack("!f", struct.pack("!f", value.value))[0]
|
|
416
|
+
except OverflowError:
|
|
417
|
+
fail("float_range", "literal exceeds float32 range", *location, "value")
|
|
418
|
+
if (
|
|
419
|
+
self.numeric_policy is not None
|
|
420
|
+
and self.numeric_policy.comparison == "exact"
|
|
421
|
+
and represented != value.value
|
|
422
|
+
):
|
|
423
|
+
fail(
|
|
424
|
+
"float_precision",
|
|
425
|
+
"exact float32 literal would require rounding or underflow",
|
|
426
|
+
*location,
|
|
427
|
+
"value",
|
|
428
|
+
)
|
|
429
|
+
if isinstance(self.temporal_policy, DatePolicy) and value.kind == "date":
|
|
430
|
+
policy = self.temporal_policy
|
|
431
|
+
lower = date.fromisoformat(policy.lower.value).toordinal() if policy.lower else None
|
|
432
|
+
upper = date.fromisoformat(policy.upper.value).toordinal() if policy.upper else None
|
|
433
|
+
self._interval(
|
|
434
|
+
date.fromisoformat(value.value).toordinal(), lower, upper, policy.closed, index
|
|
435
|
+
)
|
|
436
|
+
if isinstance(self.temporal_policy, TimestampPolicy) and value.kind == "timestamp":
|
|
437
|
+
try:
|
|
438
|
+
stamp_policy = self.temporal_policy
|
|
439
|
+
point = stamp_policy._bound(value, "value")
|
|
440
|
+
lower = (
|
|
441
|
+
stamp_policy._bound(stamp_policy.lower, "lower") if stamp_policy.lower else None
|
|
442
|
+
)
|
|
443
|
+
upper = (
|
|
444
|
+
stamp_policy._bound(stamp_policy.upper, "upper") if stamp_policy.upper else None
|
|
445
|
+
)
|
|
446
|
+
except ValidationError as exc:
|
|
447
|
+
issue = exc.errors(include_url=False, include_input=False)[0]
|
|
448
|
+
fail(issue["type"], issue["msg"], *location, *issue["loc"][1:])
|
|
449
|
+
self._interval(point, lower, upper, stamp_policy.closed, index)
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def _interval(
|
|
453
|
+
point: int, lower: int | None, upper: int | None, closed: IntervalClosure, index: int
|
|
454
|
+
) -> None:
|
|
455
|
+
before = lower is not None and (
|
|
456
|
+
point < lower or (point == lower and closed not in ("both", "left"))
|
|
457
|
+
)
|
|
458
|
+
after = upper is not None and (
|
|
459
|
+
point > upper or (point == upper and closed not in ("both", "right"))
|
|
460
|
+
)
|
|
461
|
+
if before or after:
|
|
462
|
+
fail(
|
|
463
|
+
"temporal_literal_bounds",
|
|
464
|
+
"allowed literal falls outside the declared interval",
|
|
465
|
+
"allowed_values",
|
|
466
|
+
index,
|
|
467
|
+
"value",
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class GlobalKeyScope(FrozenModel):
|
|
472
|
+
kind: Literal["global"] = "global"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class TableKeyScope(FrozenModel):
|
|
476
|
+
kind: Literal["table"] = "table"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class ColumnKeyScope(FrozenModel):
|
|
480
|
+
kind: Literal["columns"] = "columns"
|
|
481
|
+
columns: Annotated[tuple[Text, ...], Field(min_length=1)]
|
|
482
|
+
|
|
483
|
+
@model_validator(mode="after")
|
|
484
|
+
def unique_columns(self) -> Self:
|
|
485
|
+
unique(self.columns, "columns")
|
|
486
|
+
return self
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
KeyScope = Annotated[GlobalKeyScope | TableKeyScope | ColumnKeyScope, Field(discriminator="kind")]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class KeySpec(FrozenModel):
|
|
493
|
+
name: Identifier
|
|
494
|
+
columns: Annotated[tuple[Text, ...], Field(min_length=1)]
|
|
495
|
+
unique: bool
|
|
496
|
+
incomplete: Literal["reject", "allow"]
|
|
497
|
+
scope: KeyScope
|
|
498
|
+
|
|
499
|
+
@model_validator(mode="after")
|
|
500
|
+
def unique_columns(self) -> Self:
|
|
501
|
+
unique(self.columns, "columns")
|
|
502
|
+
if isinstance(self.scope, ColumnKeyScope) and set(self.scope.columns) & set(self.columns):
|
|
503
|
+
fail(
|
|
504
|
+
"key_scope_overlap",
|
|
505
|
+
"scope columns must be separate from key columns",
|
|
506
|
+
"scope",
|
|
507
|
+
"columns",
|
|
508
|
+
)
|
|
509
|
+
return self
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class SortSpec(FrozenModel):
|
|
513
|
+
column: Text
|
|
514
|
+
direction: Literal["ascending", "descending"]
|
|
515
|
+
nulls: Literal["first", "last"]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class IgnoredOrder(FrozenModel):
|
|
519
|
+
kind: Literal["ignored"] = "ignored"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class SignificantOrder(FrozenModel):
|
|
523
|
+
kind: Literal["significant"] = "significant"
|
|
524
|
+
by: Annotated[tuple[SortSpec, ...], Field(min_length=1)]
|
|
525
|
+
ties: Literal["preserve", "reject"]
|
|
526
|
+
|
|
527
|
+
@model_validator(mode="after")
|
|
528
|
+
def unique_columns(self) -> Self:
|
|
529
|
+
unique(tuple(item.column for item in self.by), "by")
|
|
530
|
+
return self
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
OrderPolicy = Annotated[IgnoredOrder | SignificantOrder, Field(discriminator="kind")]
|
|
534
|
+
Multiplicity = Literal["preserve", "distinct"]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class TableSchema(FrozenModel):
|
|
538
|
+
columns: Annotated[tuple[ColumnSpec, ...], Field(min_length=1)]
|
|
539
|
+
keys: tuple[KeySpec, ...]
|
|
540
|
+
multiplicity: Multiplicity
|
|
541
|
+
order: OrderPolicy
|
|
542
|
+
|
|
543
|
+
@model_validator(mode="after")
|
|
544
|
+
def valid_references(self) -> Self:
|
|
545
|
+
names = tuple(column.name for column in self.columns)
|
|
546
|
+
for index, name in enumerate(names):
|
|
547
|
+
if name in names[:index]:
|
|
548
|
+
fail("duplicate_column", "column names must be unique", "columns", index, "name")
|
|
549
|
+
key_names = tuple(key.name for key in self.keys)
|
|
550
|
+
for index, name in enumerate(key_names):
|
|
551
|
+
if name in key_names[:index]:
|
|
552
|
+
fail("duplicate_key", "key names must be unique", "keys", index, "name")
|
|
553
|
+
for index, key in enumerate(self.keys):
|
|
554
|
+
scope_columns = key.scope.columns if isinstance(key.scope, ColumnKeyScope) else ()
|
|
555
|
+
for path, values in (
|
|
556
|
+
(("columns",), key.columns),
|
|
557
|
+
(("scope", "columns"), scope_columns),
|
|
558
|
+
):
|
|
559
|
+
for ref_index, ref in enumerate(values):
|
|
560
|
+
if ref not in names:
|
|
561
|
+
fail(
|
|
562
|
+
"unknown_column",
|
|
563
|
+
"unknown key column: " + ref,
|
|
564
|
+
"keys",
|
|
565
|
+
index,
|
|
566
|
+
*path,
|
|
567
|
+
ref_index,
|
|
568
|
+
)
|
|
569
|
+
if isinstance(self.order, SignificantOrder):
|
|
570
|
+
for index, item in enumerate(self.order.by):
|
|
571
|
+
if item.column not in names:
|
|
572
|
+
fail(
|
|
573
|
+
"unknown_column",
|
|
574
|
+
"unknown order column: " + item.column,
|
|
575
|
+
"order",
|
|
576
|
+
"by",
|
|
577
|
+
index,
|
|
578
|
+
"column",
|
|
579
|
+
)
|
|
580
|
+
return self
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
class TableSpec(FrozenModel):
|
|
584
|
+
name: Identifier
|
|
585
|
+
schema_spec: TableSchema
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class SchemaCheck(FrozenModel):
|
|
589
|
+
kind: Literal["schema"] = "schema"
|
|
590
|
+
table: Identifier
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class KeyCheck(FrozenModel):
|
|
594
|
+
kind: Literal["key"] = "key"
|
|
595
|
+
table: Identifier
|
|
596
|
+
key: Identifier
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class CardinalityCheck(FrozenModel):
|
|
600
|
+
kind: Literal["cardinality"] = "cardinality"
|
|
601
|
+
table: Identifier
|
|
602
|
+
minimum: NonNegativeInt
|
|
603
|
+
maximum: NonNegativeInt | None = None
|
|
604
|
+
|
|
605
|
+
@model_validator(mode="after")
|
|
606
|
+
def consistent_bounds(self) -> Self:
|
|
607
|
+
if self.maximum is not None and self.maximum < self.minimum:
|
|
608
|
+
fail("cardinality_bounds", "maximum must be at least minimum", "maximum")
|
|
609
|
+
return self
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class JoinCheck(FrozenModel):
|
|
613
|
+
kind: Literal["join"] = "join"
|
|
614
|
+
left_table: Identifier
|
|
615
|
+
right_table: Identifier
|
|
616
|
+
left_columns: Annotated[tuple[Text, ...], Field(min_length=1)]
|
|
617
|
+
right_columns: Annotated[tuple[Text, ...], Field(min_length=1)]
|
|
618
|
+
relationship: Literal["one_to_one", "one_to_many", "many_to_one", "many_to_many"]
|
|
619
|
+
unmatched: Literal["allow", "reject"]
|
|
620
|
+
|
|
621
|
+
@model_validator(mode="after")
|
|
622
|
+
def paired_columns(self) -> Self:
|
|
623
|
+
unique(self.left_columns, "left_columns")
|
|
624
|
+
unique(self.right_columns, "right_columns")
|
|
625
|
+
if len(self.left_columns) != len(self.right_columns):
|
|
626
|
+
fail("join_arity", "join column lists must have equal length", "right_columns")
|
|
627
|
+
return self
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class OrderCheck(FrozenModel):
|
|
631
|
+
kind: Literal["order"] = "order"
|
|
632
|
+
table: Identifier
|
|
633
|
+
columns: Annotated[tuple[Text, ...], Field(min_length=1)]
|
|
634
|
+
|
|
635
|
+
@model_validator(mode="after")
|
|
636
|
+
def unique_columns(self) -> Self:
|
|
637
|
+
unique(self.columns, "columns")
|
|
638
|
+
return self
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class AggregateCheck(FrozenModel):
|
|
642
|
+
kind: Literal["aggregate"] = "aggregate"
|
|
643
|
+
table: Identifier
|
|
644
|
+
group_by: tuple[Text, ...]
|
|
645
|
+
measure: Text | None
|
|
646
|
+
function: Literal["sum", "count", "min", "max"]
|
|
647
|
+
output_table: Identifier
|
|
648
|
+
output_column: Text
|
|
649
|
+
|
|
650
|
+
@model_validator(mode="after")
|
|
651
|
+
def compatible_measure(self) -> Self:
|
|
652
|
+
unique(self.group_by, "group_by")
|
|
653
|
+
if self.function != "count" and self.measure is None:
|
|
654
|
+
fail("missing_measure", "this aggregation requires a measure", "measure")
|
|
655
|
+
return self
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
RuleDefinition = Annotated[
|
|
659
|
+
SchemaCheck | KeyCheck | CardinalityCheck | JoinCheck | OrderCheck | AggregateCheck,
|
|
660
|
+
Field(discriminator="kind"),
|
|
661
|
+
]
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class PreconditionSpec(FrozenModel):
|
|
665
|
+
precondition_id: Identifier
|
|
666
|
+
requirement: RuleDefinition
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class RuleSpec(FrozenModel):
|
|
670
|
+
rule_id: Identifier
|
|
671
|
+
revision: PositiveInt
|
|
672
|
+
specification: RuleDefinition
|
|
673
|
+
severity: Literal["info", "warning", "error"]
|
|
674
|
+
required: bool
|
|
675
|
+
applicability: tuple[Identifier, ...] = ()
|
|
676
|
+
|
|
677
|
+
@model_validator(mode="after")
|
|
678
|
+
def unique_preconditions(self) -> Self:
|
|
679
|
+
unique(self.applicability, "applicability")
|
|
680
|
+
return self
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
class Contract(FrozenModel):
|
|
684
|
+
schema_version: Literal["1.0"]
|
|
685
|
+
contract_id: Identifier
|
|
686
|
+
revision: PositiveInt
|
|
687
|
+
family: Family
|
|
688
|
+
inputs: Annotated[tuple[TableSpec, ...], Field(min_length=1)]
|
|
689
|
+
output: TableSpec
|
|
690
|
+
preconditions: tuple[PreconditionSpec, ...]
|
|
691
|
+
rules: tuple[RuleSpec, ...]
|
|
692
|
+
|
|
693
|
+
@model_validator(mode="after")
|
|
694
|
+
def valid_references(self) -> Self:
|
|
695
|
+
tables = (*self.inputs, self.output)
|
|
696
|
+
input_names = tuple(table.name for table in self.inputs)
|
|
697
|
+
for index, name in enumerate(input_names):
|
|
698
|
+
if name in input_names[:index]:
|
|
699
|
+
fail("duplicate_table", "table names must be unique", "inputs", index, "name")
|
|
700
|
+
if self.output.name in input_names:
|
|
701
|
+
fail("duplicate_table", "output name must differ from input names", "output", "name")
|
|
702
|
+
unique(tuple(item.precondition_id for item in self.preconditions), "preconditions")
|
|
703
|
+
unique(tuple(item.rule_id for item in self.rules), "rules")
|
|
704
|
+
catalog = {table.name: table.schema_spec for table in tables}
|
|
705
|
+
preconditions = {item.precondition_id for item in self.preconditions}
|
|
706
|
+
for index, item in enumerate(self.preconditions):
|
|
707
|
+
self._definition(item.requirement, catalog, ("preconditions", index, "requirement"))
|
|
708
|
+
for index, rule in enumerate(self.rules):
|
|
709
|
+
self._definition(rule.specification, catalog, ("rules", index, "specification"))
|
|
710
|
+
for ref_index, ref in enumerate(rule.applicability):
|
|
711
|
+
if ref not in preconditions:
|
|
712
|
+
fail(
|
|
713
|
+
"unknown_precondition",
|
|
714
|
+
"unknown precondition: " + ref,
|
|
715
|
+
"rules",
|
|
716
|
+
index,
|
|
717
|
+
"applicability",
|
|
718
|
+
ref_index,
|
|
719
|
+
)
|
|
720
|
+
return self
|
|
721
|
+
|
|
722
|
+
@staticmethod
|
|
723
|
+
def _definition(
|
|
724
|
+
definition: RuleDefinition,
|
|
725
|
+
catalog: dict[str, TableSchema],
|
|
726
|
+
location: tuple[str | int, ...],
|
|
727
|
+
) -> None:
|
|
728
|
+
def table(name: str, field: str) -> TableSchema:
|
|
729
|
+
if name not in catalog:
|
|
730
|
+
fail("unknown_table", "unknown table: " + name, *location, field)
|
|
731
|
+
return catalog[name]
|
|
732
|
+
|
|
733
|
+
def columns(name: str, refs: tuple[str, ...], field: str) -> None:
|
|
734
|
+
names = {column.name for column in catalog[name].columns}
|
|
735
|
+
for index, ref in enumerate(refs):
|
|
736
|
+
if ref not in names:
|
|
737
|
+
suffix: tuple[str | int, ...] = (
|
|
738
|
+
(field,) if field in ("measure", "output_column") else (field, index)
|
|
739
|
+
)
|
|
740
|
+
fail("unknown_column", "unknown column: " + ref, *location, *suffix)
|
|
741
|
+
|
|
742
|
+
if isinstance(definition, JoinCheck):
|
|
743
|
+
left = table(definition.left_table, "left_table")
|
|
744
|
+
right = table(definition.right_table, "right_table")
|
|
745
|
+
columns(definition.left_table, definition.left_columns, "left_columns")
|
|
746
|
+
columns(definition.right_table, definition.right_columns, "right_columns")
|
|
747
|
+
left_columns = {col.name: col for col in left.columns}
|
|
748
|
+
right_columns = {col.name: col for col in right.columns}
|
|
749
|
+
for index, (lcol, rcol) in enumerate(
|
|
750
|
+
zip(definition.left_columns, definition.right_columns, strict=True)
|
|
751
|
+
):
|
|
752
|
+
if left_columns[lcol].logical_type != right_columns[rcol].logical_type:
|
|
753
|
+
fail(
|
|
754
|
+
"join_type",
|
|
755
|
+
"join columns require identical logical types",
|
|
756
|
+
*location,
|
|
757
|
+
"right_columns",
|
|
758
|
+
index,
|
|
759
|
+
)
|
|
760
|
+
if not _same_units(left_columns[lcol], right_columns[rcol]):
|
|
761
|
+
fail(
|
|
762
|
+
"join_unit",
|
|
763
|
+
"join columns require identical numeric units",
|
|
764
|
+
*location,
|
|
765
|
+
"right_columns",
|
|
766
|
+
index,
|
|
767
|
+
)
|
|
768
|
+
return
|
|
769
|
+
schema = table(definition.table, "table")
|
|
770
|
+
if isinstance(definition, KeyCheck):
|
|
771
|
+
if definition.key not in {key.name for key in schema.keys}:
|
|
772
|
+
fail("unknown_key", "unknown key: " + definition.key, *location, "key")
|
|
773
|
+
elif isinstance(definition, OrderCheck):
|
|
774
|
+
columns(definition.table, definition.columns, "columns")
|
|
775
|
+
if not isinstance(schema.order, SignificantOrder) or definition.columns != tuple(
|
|
776
|
+
item.column for item in schema.order.by
|
|
777
|
+
):
|
|
778
|
+
fail(
|
|
779
|
+
"order_policy",
|
|
780
|
+
"order check must reference the complete declared significant order",
|
|
781
|
+
*location,
|
|
782
|
+
"columns",
|
|
783
|
+
)
|
|
784
|
+
elif isinstance(definition, AggregateCheck):
|
|
785
|
+
output = table(definition.output_table, "output_table")
|
|
786
|
+
columns(definition.table, definition.group_by, "group_by")
|
|
787
|
+
columns(definition.output_table, definition.group_by, "group_by")
|
|
788
|
+
columns(definition.output_table, (definition.output_column,), "output_column")
|
|
789
|
+
source = {col.name: col for col in schema.columns}
|
|
790
|
+
target = {col.name: col for col in output.columns}
|
|
791
|
+
for index, name in enumerate(definition.group_by):
|
|
792
|
+
if source[name].logical_type != target[name].logical_type:
|
|
793
|
+
fail(
|
|
794
|
+
"aggregate_group_type",
|
|
795
|
+
"grouping types must be preserved",
|
|
796
|
+
*location,
|
|
797
|
+
"group_by",
|
|
798
|
+
index,
|
|
799
|
+
)
|
|
800
|
+
if not _same_units(source[name], target[name]):
|
|
801
|
+
fail(
|
|
802
|
+
"aggregate_group_unit",
|
|
803
|
+
"grouping units must be preserved",
|
|
804
|
+
*location,
|
|
805
|
+
"group_by",
|
|
806
|
+
index,
|
|
807
|
+
)
|
|
808
|
+
if definition.measure is not None:
|
|
809
|
+
columns(definition.table, (definition.measure,), "measure")
|
|
810
|
+
if definition.function == "sum" and definition.measure is not None:
|
|
811
|
+
if source[definition.measure].logical_type.kind not in (
|
|
812
|
+
"integer",
|
|
813
|
+
"decimal",
|
|
814
|
+
"float",
|
|
815
|
+
):
|
|
816
|
+
fail("aggregate_type", "sum requires a numeric measure", *location, "measure")
|
|
817
|
+
if not _sum_type(
|
|
818
|
+
source[definition.measure].logical_type,
|
|
819
|
+
target[definition.output_column].logical_type,
|
|
820
|
+
):
|
|
821
|
+
fail(
|
|
822
|
+
"aggregate_type",
|
|
823
|
+
"sum must preserve numeric family without narrowing its value domain",
|
|
824
|
+
*location,
|
|
825
|
+
"output_column",
|
|
826
|
+
)
|
|
827
|
+
if (
|
|
828
|
+
definition.function == "count"
|
|
829
|
+
and target[definition.output_column].logical_type.kind != "integer"
|
|
830
|
+
):
|
|
831
|
+
fail("aggregate_type", "count output must be integer", *location, "output_column")
|
|
832
|
+
if (
|
|
833
|
+
definition.function in ("min", "max")
|
|
834
|
+
and definition.measure is not None
|
|
835
|
+
and source[definition.measure].logical_type
|
|
836
|
+
!= target[definition.output_column].logical_type
|
|
837
|
+
):
|
|
838
|
+
fail(
|
|
839
|
+
"aggregate_type",
|
|
840
|
+
"min/max must preserve the logical type",
|
|
841
|
+
*location,
|
|
842
|
+
"output_column",
|
|
843
|
+
)
|
|
844
|
+
if (
|
|
845
|
+
definition.function in ("sum", "min", "max")
|
|
846
|
+
and definition.measure is not None
|
|
847
|
+
and not _same_units(source[definition.measure], target[definition.output_column])
|
|
848
|
+
):
|
|
849
|
+
fail(
|
|
850
|
+
"aggregate_unit",
|
|
851
|
+
"sum/min/max must preserve numeric units",
|
|
852
|
+
*location,
|
|
853
|
+
"output_column",
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _same_units(source: ColumnSpec, target: ColumnSpec) -> bool:
|
|
858
|
+
return (
|
|
859
|
+
source.numeric_policy is None
|
|
860
|
+
or target.numeric_policy is None
|
|
861
|
+
or source.numeric_policy.unit == target.numeric_policy.unit
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _sum_type(source: LogicalType, target: LogicalType) -> bool:
|
|
866
|
+
if isinstance(source, IntegerType) and isinstance(target, IntegerType):
|
|
867
|
+
source_lower = -(2 ** (source.bits - 1)) if source.signed else 0
|
|
868
|
+
target_lower = -(2 ** (target.bits - 1)) if target.signed else 0
|
|
869
|
+
source_upper = 2 ** (source.bits - int(source.signed)) - 1
|
|
870
|
+
target_upper = 2 ** (target.bits - int(target.signed)) - 1
|
|
871
|
+
return target_lower <= source_lower and target_upper >= source_upper
|
|
872
|
+
if isinstance(source, DecimalType) and isinstance(target, DecimalType):
|
|
873
|
+
return (
|
|
874
|
+
target.scale >= source.scale
|
|
875
|
+
and target.precision - target.scale >= source.precision - source.scale
|
|
876
|
+
)
|
|
877
|
+
if isinstance(source, FloatType) and isinstance(target, FloatType):
|
|
878
|
+
return target.bits >= source.bits
|
|
879
|
+
return False
|