sqlassert 0.1.2__py3-none-any.whl → 0.3.1__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.
- sqlassert/__init__.py +34 -9
- sqlassert/__main__.py +138 -0
- sqlassert/check.py +444 -0
- sqlassert/diagnostics.py +57 -0
- sqlassert/dialects.py +211 -0
- sqlassert/discovery/__init__.py +30 -0
- sqlassert/discovery/bigquery.py +71 -0
- sqlassert/discovery/databricks.py +18 -0
- sqlassert/discovery/duckdb.py +163 -0
- sqlassert/discovery/fetcher.py +402 -0
- sqlassert/discovery/information_schema.py +326 -0
- sqlassert/discovery/mysql.py +70 -0
- sqlassert/discovery/postgres.py +143 -0
- sqlassert/discovery/redshift.py +36 -0
- sqlassert/discovery/snowflake.py +63 -0
- sqlassert/discovery/trino.py +37 -0
- sqlassert/improvements.py +528 -0
- sqlassert/ir/__init__.py +56 -0
- sqlassert/ir/convert.py +2377 -0
- sqlassert/ir/model.py +676 -0
- sqlassert/main.py +57 -0
- sqlassert/properties.py +121 -0
- sqlassert/provenance.py +73 -0
- sqlassert/python_engine.py +299 -0
- sqlassert/reporting.py +102 -0
- sqlassert/rules/STYLEGUIDE.md +19 -0
- sqlassert/rules/__init__.py +11 -0
- sqlassert/rules/column_set.py +34 -0
- sqlassert/rules/domain.py +376 -0
- sqlassert/rules/equality.py +233 -0
- sqlassert/rules/exactly_one.py +75 -0
- sqlassert/rules/foreign_key.py +316 -0
- sqlassert/rules/join_cardinality.py +94 -0
- sqlassert/rules/nullability.py +418 -0
- sqlassert/rules/uniqueness.py +601 -0
- sqlassert/smells.py +306 -0
- sqlassert/sql_parse.py +974 -0
- sqlassert/suggest.py +339 -0
- sqlassert-0.3.1.dist-info/METADATA +725 -0
- sqlassert-0.3.1.dist-info/RECORD +44 -0
- {sqlassert-0.1.2.dist-info → sqlassert-0.3.1.dist-info}/WHEEL +1 -1
- sqlassert-0.3.1.dist-info/entry_points.txt +2 -0
- sqlassert-0.3.1.dist-info/licenses/LICENSE +21 -0
- sqlassert/unique.py +0 -605
- sqlassert-0.1.2.dist-info/METADATA +0 -173
- sqlassert-0.1.2.dist-info/RECORD +0 -6
- {sqlassert-0.1.2.dist-info → sqlassert-0.3.1.dist-info}/top_level.txt +0 -0
sqlassert/ir/model.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""Framework-independent relational intermediate representation.
|
|
2
|
+
|
|
3
|
+
The IR is an immutable semantic object graph. Nodes refer directly to other
|
|
4
|
+
nodes and carry no identifiers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field, fields
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING, Literal, assert_never, cast, dataclass_transform
|
|
12
|
+
|
|
13
|
+
from sqlassert.provenance import AttachmentSite, Origin
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from sqlassert.properties import Property
|
|
17
|
+
|
|
18
|
+
type JoinKind = Literal["inner", "cross", "left", "right", "full", "semi", "anti"]
|
|
19
|
+
type SetOperator = Literal["union", "intersect", "except"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass_transform(eq_default=False, frozen_default=True, kw_only_default=True)
|
|
23
|
+
class NodeMeta(type):
|
|
24
|
+
"""Turn every Node subclass into the same kind of semantic value."""
|
|
25
|
+
|
|
26
|
+
_subclasses: list
|
|
27
|
+
|
|
28
|
+
def __new__(metaclass, name, bases, namespace, *, abstract: bool = False):
|
|
29
|
+
node_type = super().__new__(metaclass, name, bases, namespace)
|
|
30
|
+
node_type = dataclass(frozen=True, eq=False, kw_only=True)(node_type)
|
|
31
|
+
node_type.__ir_abstract__ = abstract
|
|
32
|
+
node_type._subclasses = []
|
|
33
|
+
if not abstract:
|
|
34
|
+
for base in node_type.__mro__[1:]:
|
|
35
|
+
match base:
|
|
36
|
+
case NodeMeta():
|
|
37
|
+
base._subclasses.append(node_type)
|
|
38
|
+
return node_type
|
|
39
|
+
|
|
40
|
+
def __call__(cls, *args, **kwargs):
|
|
41
|
+
if getattr(cls, "__ir_abstract__", False):
|
|
42
|
+
raise TypeError(f"cannot construct abstract IR node {cls.__name__}")
|
|
43
|
+
return super().__call__(*args, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Node(metaclass=NodeMeta, abstract=True):
|
|
47
|
+
"""An immutable semantic node with object-identity equality."""
|
|
48
|
+
|
|
49
|
+
origin: Origin
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScalarExpr(Node, abstract=True):
|
|
53
|
+
"""A scalar expression produced or consumed by a relational operation."""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def closed(self) -> AnyScalar:
|
|
57
|
+
"""This node as one of the concrete ScalarExprs, to match exhaustively."""
|
|
58
|
+
return cast(AnyScalar, self)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OpaqueExpression(ScalarExpr):
|
|
62
|
+
"""An expression nothing is known about: not even which columns its value
|
|
63
|
+
depends on. A user-defined or volatile function, an aggregate or window,
|
|
64
|
+
a subquery, or a column that does not resolve."""
|
|
65
|
+
|
|
66
|
+
description: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Builtin:
|
|
71
|
+
"""A built-in operator or function whose behaviour is modelled: a pure
|
|
72
|
+
function of its arguments' values, one flag of each kind per parameter.
|
|
73
|
+
|
|
74
|
+
A `strict` parameter makes the value NULL whenever its argument is NULL.
|
|
75
|
+
The value is non-null whenever every `total` parameter's argument is
|
|
76
|
+
non-null: all False means never NULL, and `total` None means it may be NULL
|
|
77
|
+
even then (`n % 0` in DuckDB). Unless `total` is None, a strict parameter
|
|
78
|
+
is total.
|
|
79
|
+
|
|
80
|
+
An `injective` one is one-to-one in its single argument that is not an
|
|
81
|
+
integer or decimal literal, when that argument is an integer or decimal:
|
|
82
|
+
`a + 1`, `10 - a`, `-a`. Only where exact arithmetic raises an error on
|
|
83
|
+
overflow instead of wrapping, saturating, or turning to a float.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
name: str
|
|
87
|
+
strict: tuple[bool, ...]
|
|
88
|
+
total: tuple[bool, ...] | None
|
|
89
|
+
injective: bool
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
assert self.total is None or len(self.total) == len(self.strict), f"{self.name}: one total flag per parameter"
|
|
93
|
+
assert self.total is None or all(total for strict, total in zip(self.strict, self.total) if strict), (
|
|
94
|
+
f"{self.name}: a strict parameter is total"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def accepts(self, count: int) -> bool:
|
|
98
|
+
return count == len(self.strict)
|
|
99
|
+
|
|
100
|
+
def strict_flags(self, count: int) -> tuple[bool, ...]:
|
|
101
|
+
return self.strict
|
|
102
|
+
|
|
103
|
+
def total_flags(self, count: int) -> tuple[bool, ...] | None:
|
|
104
|
+
return self.total
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class BuiltinVariadic(Builtin):
|
|
109
|
+
"""A Builtin whose last parameter repeats any number of times (none
|
|
110
|
+
included), each repetition with the `repeated_` flags:
|
|
111
|
+
`concat_ws(sep, value, ...)` is strict and total in `sep` alone."""
|
|
112
|
+
|
|
113
|
+
repeated_strict: bool
|
|
114
|
+
repeated_total: bool
|
|
115
|
+
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
super().__post_init__()
|
|
118
|
+
assert not self.injective, f"{self.name}: a variadic Builtin is not injective"
|
|
119
|
+
assert self.total is None or self.repeated_total or not self.repeated_strict, f"{self.name}: a strict parameter is total"
|
|
120
|
+
|
|
121
|
+
def accepts(self, count: int) -> bool:
|
|
122
|
+
return count >= len(self.strict)
|
|
123
|
+
|
|
124
|
+
def strict_flags(self, count: int) -> tuple[bool, ...]:
|
|
125
|
+
return self.strict + (self.repeated_strict,) * (count - len(self.strict))
|
|
126
|
+
|
|
127
|
+
def total_flags(self, count: int) -> tuple[bool, ...] | None:
|
|
128
|
+
return None if self.total is None else self.total + (self.repeated_total,) * (count - len(self.total))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Call(ScalarExpr):
|
|
132
|
+
"""A Builtin applied to arguments: `n + 1`, `n > 0`, `CAST(n AS BIGINT)`, `NOT p`."""
|
|
133
|
+
|
|
134
|
+
function: Builtin
|
|
135
|
+
arguments: tuple[ScalarExpr, ...]
|
|
136
|
+
description: str
|
|
137
|
+
|
|
138
|
+
def __post_init__(self) -> None:
|
|
139
|
+
assert self.function.accepts(len(self.arguments)), f"{self.function.name} takes no {len(self.arguments)} arguments"
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def strict_arguments(self) -> tuple[ScalarExpr, ...]:
|
|
143
|
+
"""The arguments whose NULL makes the value NULL."""
|
|
144
|
+
flags = self.function.strict_flags(len(self.arguments))
|
|
145
|
+
return tuple(argument for argument, strict in zip(self.arguments, flags) if strict)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def total_arguments(self) -> tuple[ScalarExpr, ...] | None:
|
|
149
|
+
"""The arguments whose being non-null makes the value non-null (none:
|
|
150
|
+
never NULL), or None when the value may be NULL regardless."""
|
|
151
|
+
flags = self.function.total_flags(len(self.arguments))
|
|
152
|
+
return None if flags is None else tuple(argument for argument, total in zip(self.arguments, flags) if total)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def injective_argument(self) -> ScalarExpr | None:
|
|
156
|
+
"""The argument the value is one-to-one in, when it is an integer or
|
|
157
|
+
decimal: the single one that is not an integer or decimal literal."""
|
|
158
|
+
if not self.function.injective:
|
|
159
|
+
return None
|
|
160
|
+
others = [argument for argument in self.arguments if not _exact_literal(argument)]
|
|
161
|
+
return others[0] if len(others) == 1 else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class OpaquePureFunction(ScalarExpr):
|
|
165
|
+
"""A built-in function or operator whose behaviour is not modelled: a pure
|
|
166
|
+
function of its arguments, and nothing else is known about it. Searched
|
|
167
|
+
CASE, `concat`, `||`, `greatest`, `IS DISTINCT FROM`, and the like."""
|
|
168
|
+
|
|
169
|
+
arguments: tuple[ScalarExpr, ...]
|
|
170
|
+
description: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Count(ScalarExpr):
|
|
174
|
+
"""`count(*)` or `count(...)`: never NULL, even over no rows; its value is
|
|
175
|
+
otherwise opaque."""
|
|
176
|
+
|
|
177
|
+
description: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class LiteralValue:
|
|
182
|
+
"""A non-null SQL literal's value: its SQL type and canonical text.
|
|
183
|
+
|
|
184
|
+
`type` is "string", "numeric", or "boolean". Two values are the same only
|
|
185
|
+
when both match, so numeric 1 never collides with string '1'. A string's
|
|
186
|
+
text is its contents; a boolean's is TRUE or FALSE. A numeric literal's
|
|
187
|
+
text is its spelling, which carries its SQL type: an integer is written
|
|
188
|
+
without sign or leading zeros (`+007` is `7`), while a decimal keeps its
|
|
189
|
+
scale (`1.0` is not `1`) and an exponent form stays a double (`1e0` is
|
|
190
|
+
not `1`). Those differ in how a column is compared with them, so only
|
|
191
|
+
identical literals are the same value; whether distinct ones can equal one
|
|
192
|
+
value is a separate, conservative question (`rules/domain.py`).
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
type: Literal["string", "numeric", "boolean"]
|
|
196
|
+
text: str
|
|
197
|
+
|
|
198
|
+
def sql(self) -> str:
|
|
199
|
+
match self.type:
|
|
200
|
+
case "string":
|
|
201
|
+
return "'" + self.text.replace("'", "''") + "'"
|
|
202
|
+
case "numeric" | "boolean":
|
|
203
|
+
return self.text
|
|
204
|
+
case _:
|
|
205
|
+
assert_never(self.type)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Constant(ScalarExpr):
|
|
209
|
+
"""A non-null literal; `value` is None while its type is not modelled."""
|
|
210
|
+
|
|
211
|
+
value: LiteralValue | None = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class NullLiteral(ScalarExpr):
|
|
215
|
+
"""The NULL literal, kept apart from Constant so it is never taken as non-null."""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Coalesce(ScalarExpr):
|
|
219
|
+
"""The first non-null argument: `coalesce`, `ifnull`, and `nvl`."""
|
|
220
|
+
|
|
221
|
+
arguments: tuple[ScalarExpr, ...]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SimpleCase(ScalarExpr):
|
|
225
|
+
"""`CASE operand WHEN whens[i] THEN thens[i] ... ELSE default END`.
|
|
226
|
+
|
|
227
|
+
Each arm compares `operand = whens[i]`; a NULL operand matches none. A
|
|
228
|
+
missing ELSE is lowered as a NULL literal `default`.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
operand: ScalarExpr
|
|
232
|
+
whens: tuple[ScalarExpr, ...]
|
|
233
|
+
thens: tuple[ScalarExpr, ...]
|
|
234
|
+
default: ScalarExpr
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class AnyAggregate(ScalarExpr):
|
|
238
|
+
"""An arbitrary value selected from the input rows of an aggregate group."""
|
|
239
|
+
|
|
240
|
+
input: ScalarExpr
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Elements(ScalarExpr):
|
|
244
|
+
"""Every element of a composite `operand` (an ARRAY's elements, a ROW's
|
|
245
|
+
fields, a MAP's values, nested ones included) as one value, non-null when
|
|
246
|
+
each element is. Not a value SQL computes: a comparison of composites
|
|
247
|
+
takes one per composite operand as a non-strict argument, since
|
|
248
|
+
`ARRAY[1, NULL] <> ARRAY[1, NULL]` and `ROW(1, NULL) = ROW(1, NULL)` may be NULL."""
|
|
249
|
+
|
|
250
|
+
operand: ScalarExpr
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ColumnRef(ScalarExpr):
|
|
254
|
+
"""A direct reference to an upstream relation expression's output."""
|
|
255
|
+
|
|
256
|
+
column: OutputColumn
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Equality(ScalarExpr):
|
|
260
|
+
"""`left = right`. As a conjunct it holds only on rows where both sides are non-null."""
|
|
261
|
+
|
|
262
|
+
left: ScalarExpr
|
|
263
|
+
right: ScalarExpr
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class InList(ScalarExpr):
|
|
267
|
+
"""`operand IN (values...)` over a list of values, not a subquery."""
|
|
268
|
+
|
|
269
|
+
operand: ScalarExpr
|
|
270
|
+
values: tuple[ScalarExpr, ...]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class IsNull(ScalarExpr):
|
|
274
|
+
"""`operand IS NULL`."""
|
|
275
|
+
|
|
276
|
+
operand: ScalarExpr
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class IsNotNull(ScalarExpr):
|
|
280
|
+
"""`operand IS NOT NULL`."""
|
|
281
|
+
|
|
282
|
+
operand: ScalarExpr
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class And(ScalarExpr):
|
|
286
|
+
"""`p AND q ...`, flattened: TRUE when every part is."""
|
|
287
|
+
|
|
288
|
+
parts: tuple[ScalarExpr, ...]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class Or(ScalarExpr):
|
|
292
|
+
"""`p OR q ...`, flattened: TRUE when some part is."""
|
|
293
|
+
|
|
294
|
+
parts: tuple[ScalarExpr, ...]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def conjuncts(predicate: ScalarExpr | None) -> tuple[ScalarExpr, ...]:
|
|
298
|
+
"""The parts of `predicate`'s top-level AND; none for no predicate."""
|
|
299
|
+
match predicate:
|
|
300
|
+
case None:
|
|
301
|
+
return ()
|
|
302
|
+
case And(parts=parts):
|
|
303
|
+
return parts
|
|
304
|
+
case _:
|
|
305
|
+
return (predicate,)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def determinants(expression: ScalarExpr) -> tuple[OutputColumn, ...] | None:
|
|
309
|
+
"""The columns `expression` is a pure function of, in order of first use,
|
|
310
|
+
or None when it is not one of any: its value may depend on other rows, on
|
|
311
|
+
state, or on nothing known (Paulley §3.1.4)."""
|
|
312
|
+
expression = expression.closed
|
|
313
|
+
match expression:
|
|
314
|
+
case ColumnRef(column=column):
|
|
315
|
+
return (column,)
|
|
316
|
+
case OpaqueExpression() | Count() | AnyAggregate():
|
|
317
|
+
return None
|
|
318
|
+
case Constant() | NullLiteral() | Call() | OpaquePureFunction() | Coalesce() | SimpleCase() | Elements() | Equality() | InList() | IsNull() | IsNotNull() | And() | Or():
|
|
319
|
+
found: dict[OutputColumn, None] = {}
|
|
320
|
+
for child in children(expression):
|
|
321
|
+
assert isinstance(child, ScalarExpr)
|
|
322
|
+
inputs = determinants(child)
|
|
323
|
+
if inputs is None:
|
|
324
|
+
return None
|
|
325
|
+
found.update(dict.fromkeys(inputs))
|
|
326
|
+
return tuple(found)
|
|
327
|
+
case _:
|
|
328
|
+
assert_never(expression)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def injective_input(expression: ScalarExpr) -> OutputColumn | None:
|
|
332
|
+
"""The one column `expression` is a one-to-one function of, given that the
|
|
333
|
+
column is an integer or decimal: the column itself, or an injective
|
|
334
|
+
Builtin of such an expression and integer or decimal literals."""
|
|
335
|
+
expression = expression.closed
|
|
336
|
+
match expression:
|
|
337
|
+
case ColumnRef(column=column):
|
|
338
|
+
return column
|
|
339
|
+
case Call(injective_argument=ScalarExpr() as argument):
|
|
340
|
+
return injective_input(argument)
|
|
341
|
+
case (
|
|
342
|
+
Call() | Constant() | NullLiteral() | Coalesce() | SimpleCase() | OpaquePureFunction() | OpaqueExpression()
|
|
343
|
+
| Count() | AnyAggregate() | Elements() | Equality() | InList() | IsNull() | IsNotNull() | And() | Or()
|
|
344
|
+
):
|
|
345
|
+
return None
|
|
346
|
+
case _:
|
|
347
|
+
assert_never(expression)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _exact_literal(expression: ScalarExpr) -> bool:
|
|
351
|
+
"""An integer or decimal literal: not an exponent form, which is a double."""
|
|
352
|
+
match expression:
|
|
353
|
+
case Constant(value=LiteralValue(type="numeric", text=text)):
|
|
354
|
+
return "e" not in text
|
|
355
|
+
case _:
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def total(expression: ScalarExpr) -> bool:
|
|
360
|
+
"""Whether `expression` is non-null whenever every column it reads is: a
|
|
361
|
+
column, a literal, `count(...)`, a Builtin total in its arguments over
|
|
362
|
+
total ones, or a coalesce with one total argument. NULL, CASE (a missed
|
|
363
|
+
WHEN), the IS family and comparisons (not inferred), and unmodelled
|
|
364
|
+
functions may be NULL on non-null columns."""
|
|
365
|
+
expression = expression.closed
|
|
366
|
+
match expression:
|
|
367
|
+
case ColumnRef() | Constant() | Count():
|
|
368
|
+
return True
|
|
369
|
+
case Call(total_arguments=tuple() as arguments):
|
|
370
|
+
return all(total(argument) for argument in arguments)
|
|
371
|
+
case Coalesce(arguments=arguments):
|
|
372
|
+
return any(total(argument) for argument in arguments)
|
|
373
|
+
case (
|
|
374
|
+
Call() | NullLiteral() | SimpleCase() | OpaquePureFunction() | OpaqueExpression() | AnyAggregate() | Elements()
|
|
375
|
+
| Equality() | InList() | IsNull() | IsNotNull() | And() | Or()
|
|
376
|
+
):
|
|
377
|
+
return False
|
|
378
|
+
case _:
|
|
379
|
+
assert_never(expression)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def strict_inputs(expression: ScalarExpr) -> tuple[OutputColumn, ...]:
|
|
383
|
+
"""The columns whose NULL makes `expression` NULL, in order of first use.
|
|
384
|
+
|
|
385
|
+
A Builtin passes on its strict arguments'; `=` both sides'; `IN (...)` its
|
|
386
|
+
operand's (`1 IN (1, NULL)` is TRUE); AND and OR only a column every part
|
|
387
|
+
is strict in (`NULL OR TRUE` is TRUE). Coalesce, CASE, the IS family and
|
|
388
|
+
unmodelled functions may turn NULL into a value; a literal, `count(...)`,
|
|
389
|
+
an opaque expression or an aggregate's value has no column of its own."""
|
|
390
|
+
expression = expression.closed
|
|
391
|
+
match expression:
|
|
392
|
+
case ColumnRef(column=column):
|
|
393
|
+
return (column,)
|
|
394
|
+
case Call(strict_arguments=arguments):
|
|
395
|
+
return tuple(dict.fromkeys(column for argument in arguments for column in strict_inputs(argument)))
|
|
396
|
+
case Equality(left=left, right=right):
|
|
397
|
+
return tuple(dict.fromkeys((*strict_inputs(left), *strict_inputs(right))))
|
|
398
|
+
case InList(operand=operand):
|
|
399
|
+
return strict_inputs(operand)
|
|
400
|
+
case And(parts=(first, *others)) | Or(parts=(first, *others)):
|
|
401
|
+
rest = [strict_inputs(other) for other in others]
|
|
402
|
+
return tuple(column for column in strict_inputs(first) if all(column in columns for columns in rest))
|
|
403
|
+
case (
|
|
404
|
+
And() | Or() | Constant() | NullLiteral() | Coalesce() | SimpleCase() | OpaquePureFunction() | OpaqueExpression()
|
|
405
|
+
| Count() | AnyAggregate() | Elements() | IsNull() | IsNotNull()
|
|
406
|
+
):
|
|
407
|
+
return ()
|
|
408
|
+
case _:
|
|
409
|
+
assert_never(expression)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# Closed unions of every concrete ScalarExpr and RelationExpr, for an
|
|
413
|
+
# exhaustive `match` ending in `assert_never`. Python has no sealed classes, so
|
|
414
|
+
# a type checker treats a base class as open and cannot prove a match covers
|
|
415
|
+
# it. tests/test_ir_unions.py checks these stay in sync with the subclasses,
|
|
416
|
+
# which makes `ScalarExpr.closed` and `RelationExpr.closed` sound.
|
|
417
|
+
type AnyScalar = (
|
|
418
|
+
ColumnRef | Constant | NullLiteral | Coalesce | SimpleCase | Call | OpaquePureFunction | OpaqueExpression
|
|
419
|
+
| Count | AnyAggregate | Elements | Equality | InList | IsNull | IsNotNull | And | Or
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class OutputColumn(Node):
|
|
424
|
+
"""One named output of one relational stage and the expression producing it.
|
|
425
|
+
|
|
426
|
+
`binary_text` marks a table column declared as text compared by identity:
|
|
427
|
+
VARCHAR or TEXT, with no COLLATE, in a dialect whose default collation is
|
|
428
|
+
binary (`DialectSemantics.text_is_binary`). Equal to such a column, distinct strings are distinct values.
|
|
429
|
+
|
|
430
|
+
`identity_comparison` marks a table column whose equal values are
|
|
431
|
+
identical: an integer, decimal, boolean, date, time, timestamp or UUID
|
|
432
|
+
type, or binary text. Not a float (`-0.0 = 0.0`), an interval, or text
|
|
433
|
+
under a collation.
|
|
434
|
+
|
|
435
|
+
`exact_numeric` marks a table column declared as an integer or decimal.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
name: str
|
|
439
|
+
scalar_expr: ScalarExpr
|
|
440
|
+
binary_text: bool = False
|
|
441
|
+
# Declared SQL type where equality compares values without casts or collation.
|
|
442
|
+
comparison_type: str | None = None
|
|
443
|
+
identity_comparison: bool = False
|
|
444
|
+
exact_numeric: bool = False
|
|
445
|
+
|
|
446
|
+
def get_relation(self, program: Program) -> RelationExpr:
|
|
447
|
+
"""The one relation in ``program`` whose output includes this column."""
|
|
448
|
+
relation = program.column_to_parent_relation_index.get(self)
|
|
449
|
+
assert relation is not None, f"output column {self.name} has no parent relation in this program"
|
|
450
|
+
return relation
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class RelationExpr(Node, abstract=True):
|
|
454
|
+
"""A relation-producing expression with an explicit output schema."""
|
|
455
|
+
|
|
456
|
+
output_columns: tuple[OutputColumn, ...]
|
|
457
|
+
is_schema_complete: bool = False
|
|
458
|
+
|
|
459
|
+
@property
|
|
460
|
+
def closed(self) -> AnyRelation:
|
|
461
|
+
"""This node as one of the concrete RelationExprs, to match exhaustively."""
|
|
462
|
+
return cast(AnyRelation, self)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class RelationRole(Enum):
|
|
466
|
+
TABLE = "table"
|
|
467
|
+
VIEW = "view"
|
|
468
|
+
CTE = "cte"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class NamedRelation(RelationExpr):
|
|
472
|
+
"""A table, view, or CTE declaration shared by every reference to it."""
|
|
473
|
+
|
|
474
|
+
name: str
|
|
475
|
+
role: RelationRole
|
|
476
|
+
body: RelationExpr | None = None
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def report_name(self) -> str:
|
|
480
|
+
return f"CTE_{self.name}" if self.role is RelationRole.CTE else self.name
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class Alias(RelationExpr):
|
|
484
|
+
"""One occurrence of a relation expression in a query scope."""
|
|
485
|
+
|
|
486
|
+
source: RelationExpr
|
|
487
|
+
name: str
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class Join(RelationExpr):
|
|
491
|
+
"""A join and its ON (or USING) condition; None for a cross join.
|
|
492
|
+
|
|
493
|
+
A condition is `And` and `Or` over the recognized `Equality`, `InList`,
|
|
494
|
+
`IsNull`, and `IsNotNull`; anything else is an opaque scalar expression, so
|
|
495
|
+
the predicate is the whole condition.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
kind: JoinKind
|
|
499
|
+
left: RelationExpr
|
|
500
|
+
right: RelationExpr
|
|
501
|
+
predicate: ScalarExpr | None = None
|
|
502
|
+
# A left join a scalar subquery lowers to: its right input is the
|
|
503
|
+
# subquery's body, and each left row matches at most one right row, or
|
|
504
|
+
# the query fails (more than one row is a runtime error).
|
|
505
|
+
scalar_subquery: bool = False
|
|
506
|
+
|
|
507
|
+
@property
|
|
508
|
+
def conjuncts(self) -> tuple[ScalarExpr, ...]:
|
|
509
|
+
return conjuncts(self.predicate)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class Filter(RelationExpr):
|
|
513
|
+
"""A WHERE clause, its condition lowered as in `Join`."""
|
|
514
|
+
|
|
515
|
+
input: RelationExpr
|
|
516
|
+
predicate: ScalarExpr | None = None
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def conjuncts(self) -> tuple[ScalarExpr, ...]:
|
|
520
|
+
return conjuncts(self.predicate)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class Project(RelationExpr):
|
|
524
|
+
input: RelationExpr
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class Limit(RelationExpr):
|
|
528
|
+
"""A row subset selected by LIMIT/FETCH or OFFSET; count is a known hard bound, if any."""
|
|
529
|
+
|
|
530
|
+
input: RelationExpr
|
|
531
|
+
count: int | None = None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class AggregateCall(Node):
|
|
535
|
+
"""One aggregate function call in a grouped Select's list: its SQL, and its
|
|
536
|
+
arguments lowered against the Aggregate's input (none for `count(*)`).
|
|
537
|
+
Only smells read it; an aggregate's value is opaque to every proof."""
|
|
538
|
+
|
|
539
|
+
description: str
|
|
540
|
+
arguments: tuple[ScalarExpr, ...]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class Aggregate(RelationExpr):
|
|
544
|
+
input: RelationExpr
|
|
545
|
+
grouping_outputs: tuple[OutputColumn, ...]
|
|
546
|
+
calls: tuple[AggregateCall, ...] = ()
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class Distinct(RelationExpr):
|
|
550
|
+
input: RelationExpr
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class SetOperation(RelationExpr):
|
|
554
|
+
"""UNION, INTERSECT, or EXCEPT. Output columns refer to the left input;
|
|
555
|
+
`arms` pairs each with the right input's column at its position."""
|
|
556
|
+
|
|
557
|
+
operator: SetOperator
|
|
558
|
+
left: RelationExpr
|
|
559
|
+
right: RelationExpr
|
|
560
|
+
distinct: bool = True
|
|
561
|
+
by_name: bool = False
|
|
562
|
+
|
|
563
|
+
def arms(self, column: OutputColumn) -> tuple[OutputColumn, OutputColumn] | None:
|
|
564
|
+
"""The left and right input columns `column` is computed from, or None
|
|
565
|
+
when the inputs pair by name (`UNION BY NAME`) or differ in width (an
|
|
566
|
+
unanalyzed input whose columns are unknown)."""
|
|
567
|
+
if self.by_name or len(self.left.output_columns) != len(self.right.output_columns):
|
|
568
|
+
return None
|
|
569
|
+
position = self.output_columns.index(column)
|
|
570
|
+
return self.left.output_columns[position], self.right.output_columns[position]
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class QualifyByPartition(RelationExpr):
|
|
574
|
+
"""One row per partition: ``QUALIFY row_number() OVER (PARTITION BY ...) = 1`` or ``DISTINCT ON (...)``."""
|
|
575
|
+
|
|
576
|
+
input: RelationExpr
|
|
577
|
+
partition_outputs: tuple[OutputColumn, ...]
|
|
578
|
+
ordering: tuple[ScalarExpr, ...] = ()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class OpaqueRelation(RelationExpr):
|
|
582
|
+
description: str
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class RecursiveRelation(OpaqueRelation):
|
|
586
|
+
"""An explicit unsupported recursive reference, represented without a cycle."""
|
|
587
|
+
|
|
588
|
+
relation_name: str
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
type AnyRelation = (
|
|
592
|
+
NamedRelation | Alias | Join | Filter | Project | Limit | Aggregate | Distinct | SetOperation
|
|
593
|
+
| QualifyByPartition | OpaqueRelation | RecursiveRelation
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class Assertion(Node):
|
|
598
|
+
"""A request to prove one Property. For now its just a wrapper around a Property, but it could be extended with metadata in the future"""
|
|
599
|
+
|
|
600
|
+
property: Property
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@dataclass(frozen=True)
|
|
604
|
+
class Program:
|
|
605
|
+
named_relations: tuple[NamedRelation, ...] = () # First pass collects all named relations so that we can reference them
|
|
606
|
+
root: RelationExpr | None = None # The root of the query tree, which is the entry point for the engine to start reasoning
|
|
607
|
+
assertions: tuple[Assertion, ...] = () # The properties that the engine should attempt to prove
|
|
608
|
+
declarations: tuple[Property, ...] = () # The properties that the engine should assume to be true
|
|
609
|
+
# Every JOIN, Select Expression, and simple CASE a marker can attach to, with
|
|
610
|
+
# the Join, the relation the Select produces, or the lowered CASE.
|
|
611
|
+
attachment_sites: tuple[tuple[AttachmentSite, RelationExpr | SimpleCase], ...] = ()
|
|
612
|
+
# Each scalar subquery lowered as a join, with where its Select is written.
|
|
613
|
+
scalar_subqueries: tuple[tuple[AttachmentSite, Join], ...] = ()
|
|
614
|
+
# Each Select Expression's ORDER BY keys, at its trailing site, as columns
|
|
615
|
+
# of the stage it sorts: the input of a Project, else the Select's own
|
|
616
|
+
# relation. Empty with no ORDER BY; None when a key is an expression or a
|
|
617
|
+
# name that does not resolve to one column, and for DISTINCT ON, whose
|
|
618
|
+
# ORDER BY picks the rows it keeps.
|
|
619
|
+
sort_keys: tuple[tuple[AttachmentSite, tuple[OutputColumn, ...] | None], ...] = ()
|
|
620
|
+
# Each INTERSECT and EXCEPT, sited where its keyword follows its left
|
|
621
|
+
# input's last Select (no marker attaches there).
|
|
622
|
+
set_operations: tuple[tuple[AttachmentSite, SetOperation], ...] = ()
|
|
623
|
+
column_to_parent_relation_index: dict[OutputColumn, RelationExpr] = field(init=False, repr=False, compare=False)
|
|
624
|
+
|
|
625
|
+
def __post_init__(self) -> None:
|
|
626
|
+
# Every stage mints its own output columns, so each column has exactly one parent relation.
|
|
627
|
+
index: dict[OutputColumn, RelationExpr] = {}
|
|
628
|
+
for relation in self.relations():
|
|
629
|
+
for column in relation.output_columns:
|
|
630
|
+
parent = index.setdefault(column, relation)
|
|
631
|
+
if parent is not relation:
|
|
632
|
+
raise ValueError(
|
|
633
|
+
f"output column {column.name} belongs to both {type(parent).__name__} and {type(relation).__name__}"
|
|
634
|
+
)
|
|
635
|
+
object.__setattr__(self, "column_to_parent_relation_index", index)
|
|
636
|
+
|
|
637
|
+
def nodes(self) -> tuple[Node, ...]:
|
|
638
|
+
"""Every node reachable from the program, each once."""
|
|
639
|
+
nodes: dict[Node, None] = {}
|
|
640
|
+
|
|
641
|
+
def visit(node: Node) -> None:
|
|
642
|
+
if node in nodes:
|
|
643
|
+
return
|
|
644
|
+
nodes[node] = None
|
|
645
|
+
for child in children(node):
|
|
646
|
+
visit(child)
|
|
647
|
+
|
|
648
|
+
for node in (*self.named_relations, *self.assertions):
|
|
649
|
+
visit(node)
|
|
650
|
+
if self.root is not None:
|
|
651
|
+
visit(self.root)
|
|
652
|
+
return tuple(nodes)
|
|
653
|
+
|
|
654
|
+
def relations(self) -> tuple[RelationExpr, ...]:
|
|
655
|
+
"""Every relation reachable from the program, each once."""
|
|
656
|
+
return tuple(node for node in self.nodes() if isinstance(node, RelationExpr))
|
|
657
|
+
|
|
658
|
+
def relation_of(self, columns: frozenset[OutputColumn]) -> RelationExpr | None:
|
|
659
|
+
"""The parent relation shared by every column, or None when they span several or none."""
|
|
660
|
+
parents = {self.column_to_parent_relation_index.get(column) for column in columns}
|
|
661
|
+
if len(parents) != 1 or None in parents:
|
|
662
|
+
return None
|
|
663
|
+
parent, = parents
|
|
664
|
+
return parent
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def children(node: Node) -> tuple[Node, ...]:
|
|
668
|
+
"""Direct semantic references held by ``node``, in dataclass field order."""
|
|
669
|
+
found: list[Node] = []
|
|
670
|
+
for node_field in fields(node):
|
|
671
|
+
value = getattr(node, node_field.name)
|
|
672
|
+
if isinstance(value, Node):
|
|
673
|
+
found.append(value)
|
|
674
|
+
elif isinstance(value, tuple):
|
|
675
|
+
found.extend(item for item in value if isinstance(item, Node))
|
|
676
|
+
return tuple(found)
|