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.
Files changed (47) hide show
  1. sqlassert/__init__.py +34 -9
  2. sqlassert/__main__.py +138 -0
  3. sqlassert/check.py +444 -0
  4. sqlassert/diagnostics.py +57 -0
  5. sqlassert/dialects.py +211 -0
  6. sqlassert/discovery/__init__.py +30 -0
  7. sqlassert/discovery/bigquery.py +71 -0
  8. sqlassert/discovery/databricks.py +18 -0
  9. sqlassert/discovery/duckdb.py +163 -0
  10. sqlassert/discovery/fetcher.py +402 -0
  11. sqlassert/discovery/information_schema.py +326 -0
  12. sqlassert/discovery/mysql.py +70 -0
  13. sqlassert/discovery/postgres.py +143 -0
  14. sqlassert/discovery/redshift.py +36 -0
  15. sqlassert/discovery/snowflake.py +63 -0
  16. sqlassert/discovery/trino.py +37 -0
  17. sqlassert/improvements.py +528 -0
  18. sqlassert/ir/__init__.py +56 -0
  19. sqlassert/ir/convert.py +2377 -0
  20. sqlassert/ir/model.py +676 -0
  21. sqlassert/main.py +57 -0
  22. sqlassert/properties.py +121 -0
  23. sqlassert/provenance.py +73 -0
  24. sqlassert/python_engine.py +299 -0
  25. sqlassert/reporting.py +102 -0
  26. sqlassert/rules/STYLEGUIDE.md +19 -0
  27. sqlassert/rules/__init__.py +11 -0
  28. sqlassert/rules/column_set.py +34 -0
  29. sqlassert/rules/domain.py +376 -0
  30. sqlassert/rules/equality.py +233 -0
  31. sqlassert/rules/exactly_one.py +75 -0
  32. sqlassert/rules/foreign_key.py +316 -0
  33. sqlassert/rules/join_cardinality.py +94 -0
  34. sqlassert/rules/nullability.py +418 -0
  35. sqlassert/rules/uniqueness.py +601 -0
  36. sqlassert/smells.py +306 -0
  37. sqlassert/sql_parse.py +974 -0
  38. sqlassert/suggest.py +339 -0
  39. sqlassert-0.3.1.dist-info/METADATA +725 -0
  40. sqlassert-0.3.1.dist-info/RECORD +44 -0
  41. {sqlassert-0.1.2.dist-info → sqlassert-0.3.1.dist-info}/WHEEL +1 -1
  42. sqlassert-0.3.1.dist-info/entry_points.txt +2 -0
  43. sqlassert-0.3.1.dist-info/licenses/LICENSE +21 -0
  44. sqlassert/unique.py +0 -605
  45. sqlassert-0.1.2.dist-info/METADATA +0 -173
  46. sqlassert-0.1.2.dist-info/RECORD +0 -6
  47. {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)