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
@@ -0,0 +1,402 @@
1
+ """Hydrate a SQL Program's external Relations from a database catalog.
2
+
3
+ A `DatabaseKnowledgeFetcher` runs before the engine. It links the Program once
4
+ without a connection to find the external Named Relations it reaches, asks
5
+ its database-specific hooks for their catalog metadata and view definitions,
6
+ and relinks the Program with that metadata turned into ordinary declarations:
7
+ a fetched table becomes a column-only `CREATE TABLE`, a fetched view the
8
+ `CREATE VIEW` the database stored. Each relink reveals the Relations the new
9
+ view bodies read, so view expansion recurses until nothing new is reached; a
10
+ cycle among views is left to the linker, which reports it.
11
+
12
+ Catalog constraints never pass through those declarations. `bind` turns them
13
+ into Properties over the final IR's own Output Columns, so the engine sees
14
+ Properties only, never a name, a catalog row, or a connection: Knowledge for
15
+ what the dialect enforces, Property Declarations for unenforced keys the
16
+ caller trusts (`trust_unenforced_keys`), as `ir/convert.py` decides for keys
17
+ written in SQL.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from abc import ABC, abstractmethod
23
+ from collections.abc import Mapping
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum
26
+
27
+ from sqlglot import Expr, exp
28
+ from sqlglot.errors import SqlglotError
29
+ from sqlglot.optimizer.scope import traverse_scope
30
+
31
+ from sqlassert import diagnostics as diag
32
+ from sqlassert import ir
33
+ from sqlassert.diagnostics import Diagnostic
34
+ from sqlassert.dialects import semantics
35
+ from sqlassert.ir.convert import IrConversionResult, IrParser
36
+ from sqlassert.properties import NonNullColumn, Property, ReferentialCoverage, UniqueSet
37
+ from sqlassert.provenance import Origin
38
+ from sqlassert.sql_parse import ParsedProgram, SqlParser
39
+
40
+ CATALOG = "catalog"
41
+
42
+
43
+ class DatabaseRelationKind(Enum):
44
+ TABLE = "table"
45
+ VIEW = "view"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class DatabaseColumn:
50
+ """One catalog column. `data_type` is its SQL type as the database writes
51
+ it (in the fetcher's `type_dialect`), or None when unknown; `collation` is
52
+ the collation its comparisons use, None for the database's binary default.
53
+ A fetcher that cannot tell a column's collation leaves `data_type` None,
54
+ so no comparison is assumed to be by identity."""
55
+
56
+ name: str
57
+ nullable: bool
58
+ data_type: str | None = None
59
+ collation: str | None = None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class DatabaseForeignKey:
64
+ """An enforced foreign key: `columns` pair by position with `referenced_columns`
65
+ of the Relation whose canonical identity is `referenced`."""
66
+
67
+ columns: tuple[str, ...]
68
+ referenced: str
69
+ referenced_columns: tuple[str, ...]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class DatabaseRelationMetadata:
74
+ """One catalog Relation, identified canonically, with its complete enforced facts.
75
+
76
+ Columns, keys, unique sets, and foreign keys describe base tables only; a
77
+ view's are derived from its definition instead.
78
+ """
79
+
80
+ catalog: str
81
+ schema: str
82
+ name: str
83
+ kind: DatabaseRelationKind
84
+ columns: tuple[DatabaseColumn, ...] = ()
85
+ primary_key: tuple[str, ...] = ()
86
+ unique_sets: tuple[tuple[str, ...], ...] = ()
87
+ foreign_keys: tuple[DatabaseForeignKey, ...] = ()
88
+
89
+ @property
90
+ def identity(self) -> str:
91
+ return f"{self.catalog}.{self.schema}.{self.name}"
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class DatabaseViewDefinition:
96
+ """The `CREATE VIEW` statement a database stored for one view: structural input, never Knowledge."""
97
+
98
+ sql: str
99
+
100
+
101
+ class DatabaseMetadataError(Exception):
102
+ """The catalog could not be read, for example for lack of permission."""
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class DatabaseHydration:
107
+ """The final linked Program and the catalog tables declared into it, by declared name."""
108
+
109
+ conversion: IrConversionResult
110
+ tables: Mapping[str, DatabaseRelationMetadata] = field(default_factory=dict)
111
+ diagnostics: tuple[Diagnostic, ...] = ()
112
+ dialect: str = "duckdb"
113
+ trust_unenforced_keys: bool = True
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class CatalogFacts:
118
+ """What `bind` makes of the catalog: Knowledge the database enforces,
119
+ Property Declarations it does not but the caller trusts, and a warning
120
+ for each key left out."""
121
+
122
+ knowledge: tuple[Property, ...] = ()
123
+ declarations: tuple[Property, ...] = ()
124
+ warnings: tuple[Diagnostic, ...] = ()
125
+
126
+
127
+ class DatabaseKnowledgeFetcher(ABC):
128
+ """Generic catalog discovery; subclasses own one database's catalog boundary."""
129
+
130
+ # The SQL dialect the database writes column types in.
131
+ type_dialect: str | None = None
132
+
133
+ def hydrate(self, ast: ParsedProgram, *, dialect: str, trust_unenforced_keys: bool = True) -> DatabaseHydration:
134
+ return _Hydration(self, ast, dialect, trust_unenforced_keys).run()
135
+
136
+ def bind(self, program: ir.Program, hydration: DatabaseHydration) -> CatalogFacts:
137
+ """Catalog facts as Properties over ``program``'s Output Columns.
138
+
139
+ NOT NULL columns are Knowledge. Keys and foreign keys are Knowledge
140
+ where the dialect enforces them; elsewhere, as for SQL declarations,
141
+ they are Property Declarations under `trust_unenforced_keys`, and
142
+ otherwise left out with a warning (`docs/dialects.md`). A foreign key
143
+ binds only when the Program reaches its referenced Relation.
144
+ """
145
+ meaning = semantics(hydration.dialect)
146
+ knowledge: list[Property] = []
147
+ declarations: list[Property] = []
148
+ warnings: list[Diagnostic] = []
149
+
150
+ def place(facts: list[Property], enforced: bool, kind: str, columns: tuple[str, ...], metadata: DatabaseRelationMetadata) -> None:
151
+ if enforced:
152
+ knowledge.extend(facts)
153
+ elif hydration.trust_unenforced_keys:
154
+ declarations.extend(facts)
155
+ else:
156
+ warnings.append(Diagnostic(
157
+ diag.UNENFORCED_CONSTRAINT,
158
+ f"the {kind} ({', '.join(columns)}) of {metadata.identity} is not Knowledge, since {hydration.dialect} "
159
+ "does not enforce it: if the data satisfies it, analyze with trust_unenforced_keys=True",
160
+ _origin(metadata),
161
+ ))
162
+
163
+ tables = {
164
+ metadata.identity.lower(): (relation, metadata)
165
+ for relation in program.named_relations
166
+ if relation.role is ir.RelationRole.TABLE and (metadata := hydration.tables.get(relation.name.lower())) is not None
167
+ }
168
+ keys_enforced = meaning.unique_keys == "enforced"
169
+ for relation, metadata in tables.values():
170
+ columns = {column.name.lower(): column for column in relation.output_columns}
171
+ non_null = sorted({column.name.lower() for column in metadata.columns if not column.nullable})
172
+ knowledge.extend(NonNullColumn(column=columns[name]) for name in non_null if name in columns)
173
+ for position, unique_set in enumerate(_unique_sets(metadata)):
174
+ facts: list[Property] = [UniqueSet(columns=frozenset(columns[name.lower()] for name in unique_set))]
175
+ primary = position == 0 and bool(metadata.primary_key)
176
+ if primary and (meaning.primary_key_not_null or not keys_enforced):
177
+ facts.extend(NonNullColumn(column=columns[name.lower()]) for name in unique_set if name.lower() not in non_null)
178
+ place(facts, keys_enforced, "primary key" if primary else "unique key", unique_set, metadata)
179
+ for foreign_key in metadata.foreign_keys:
180
+ if foreign_key.referenced.lower() not in tables:
181
+ continue
182
+ target, target_metadata = tables[foreign_key.referenced.lower()]
183
+ if _incomplete_reference(foreign_key, target_metadata):
184
+ continue # Never seen from a consistent catalog; no fact is safer than a wrong one.
185
+ referenced = {column.name.lower(): column for column in target.output_columns}
186
+ place([ReferentialCoverage(
187
+ columns=tuple(columns[name.lower()] for name in foreign_key.columns),
188
+ referenced_columns=tuple(referenced[name.lower()] for name in foreign_key.referenced_columns),
189
+ )], meaning.foreign_keys == "enforced", "foreign key", foreign_key.columns, metadata)
190
+ return CatalogFacts(tuple(knowledge), tuple(declarations), tuple(dict.fromkeys(warnings)))
191
+
192
+ @abstractmethod
193
+ def fetch_relation_metadata(
194
+ self, names: tuple[str, ...], scope: DatabaseRelationMetadata | None = None
195
+ ) -> Mapping[str, DatabaseRelationMetadata]:
196
+ """Resolve query-visible ``names`` and fetch each one's complete metadata.
197
+
198
+ ``scope`` is the view whose definition contains the names, or None for
199
+ the SQL Program itself. A name absent from the result does not exist.
200
+ Raises DatabaseMetadataError when the catalog cannot be read.
201
+ """
202
+
203
+ @abstractmethod
204
+ def fetch_view_definition(self, relation: DatabaseRelationMetadata) -> DatabaseViewDefinition:
205
+ """The stored definition of one view. Raises DatabaseMetadataError when unreadable."""
206
+
207
+
208
+ def _unique_sets(metadata: DatabaseRelationMetadata) -> tuple[tuple[str, ...], ...]:
209
+ return ((metadata.primary_key,) if metadata.primary_key else ()) + metadata.unique_sets
210
+
211
+
212
+ def _incompleteness(metadata: DatabaseRelationMetadata) -> str | None:
213
+ if metadata.kind is not DatabaseRelationKind.TABLE:
214
+ return None
215
+ if not metadata.columns:
216
+ return "has no columns"
217
+ names = {column.name.lower() for column in metadata.columns}
218
+ for unique_set in _unique_sets(metadata):
219
+ if not unique_set or not {name.lower() for name in unique_set} <= names:
220
+ return f"has a key ({', '.join(unique_set)}) over columns it does not list"
221
+ for foreign_key in metadata.foreign_keys:
222
+ if not {name.lower() for name in foreign_key.columns} <= names or len(foreign_key.columns) != len(foreign_key.referenced_columns):
223
+ return f"has a foreign key ({', '.join(foreign_key.columns)}) that does not pair columns it lists"
224
+ return None
225
+
226
+
227
+ def _incomplete_reference(foreign_key: DatabaseForeignKey, target: DatabaseRelationMetadata) -> bool:
228
+ names = {column.name.lower() for column in target.columns}
229
+ return not {name.lower() for name in foreign_key.referenced_columns} <= names
230
+
231
+
232
+ class _Hydration:
233
+ """The state of hydrating one SQL Program: catalog answers are memoized for it alone."""
234
+
235
+ def __init__(self, fetcher: DatabaseKnowledgeFetcher, ast: ParsedProgram, dialect: str, trust_unenforced_keys: bool):
236
+ self.fetcher = fetcher
237
+ self.ast = ast
238
+ self.dialect = dialect
239
+ self.trust_unenforced_keys = trust_unenforced_keys
240
+ self.sql_declared = {_qualified_name(table).lower() for table in map(_created_table, ast.create_statements) if table is not None}
241
+ self.resolved: dict[str, DatabaseRelationMetadata | None] = {}
242
+ self.views: dict[str, exp.Create | None] = {}
243
+ self.declarations: dict[str, exp.Create] = {}
244
+ self.tables: dict[str, DatabaseRelationMetadata] = {}
245
+ self.unavailable: set[str] = set()
246
+ self.diagnostics: list[Diagnostic] = []
247
+
248
+ def run(self) -> DatabaseHydration:
249
+ while True:
250
+ program = ParsedProgram((*self.declarations.values(), *self.ast.create_statements), self.ast.root_select)
251
+ conversion = IrParser(self.dialect, self.trust_unenforced_keys).parse(program)
252
+ pending = tuple(dict.fromkeys(
253
+ relation.name for relation in conversion.program.named_relations if self._is_external(relation)
254
+ ))
255
+ if not pending:
256
+ return DatabaseHydration(
257
+ conversion, dict(self.tables), tuple(dict.fromkeys(self.diagnostics)), self.dialect, self.trust_unenforced_keys,
258
+ )
259
+ self.resolved.update(self._resolve(tuple(name for name in pending if name.lower() not in self.resolved), None))
260
+ for name in pending:
261
+ self._declare(name, self.resolved[name.lower()])
262
+
263
+ def _is_external(self, relation: ir.NamedRelation) -> bool:
264
+ key = relation.name.lower()
265
+ return (
266
+ relation.role is ir.RelationRole.TABLE
267
+ and key not in self.sql_declared
268
+ and key not in self.declarations
269
+ and key not in self.unavailable
270
+ )
271
+
272
+ def _resolve(self, names: tuple[str, ...], scope: DatabaseRelationMetadata | None) -> dict[str, DatabaseRelationMetadata | None]:
273
+ if not names:
274
+ return {}
275
+ where = f" read by view {scope.identity}" if scope is not None else ""
276
+ try:
277
+ found = self.fetcher.fetch_relation_metadata(names, scope)
278
+ except DatabaseMetadataError as error:
279
+ self.diagnostics.append(Diagnostic(diag.CATALOG_METADATA_UNAVAILABLE, f"catalog metadata for {', '.join(names)}{where} could not be read: {error}"))
280
+ return dict.fromkeys((name.lower() for name in names), None)
281
+ resolved: dict[str, DatabaseRelationMetadata | None] = {}
282
+ for name in names:
283
+ metadata = found.get(name)
284
+ if metadata is None:
285
+ self.diagnostics.append(Diagnostic(diag.CATALOG_RELATION_NOT_FOUND, f"relation {name}{where} is not in the database catalog"))
286
+ resolved[name.lower()] = metadata
287
+ return resolved
288
+
289
+ def _declare(self, name: str, metadata: DatabaseRelationMetadata | None) -> None:
290
+ key = name.lower()
291
+ declaration = self._declaration(name, metadata) if metadata is not None else None
292
+ if declaration is None:
293
+ self.unavailable.add(key)
294
+ return
295
+ self.declarations[key] = declaration
296
+ if metadata is not None and metadata.kind is DatabaseRelationKind.TABLE:
297
+ self.tables[key] = metadata
298
+
299
+ def _declaration(self, name: str, metadata: DatabaseRelationMetadata) -> exp.Create | None:
300
+ problem = _incompleteness(metadata)
301
+ if problem is not None:
302
+ self.diagnostics.append(Diagnostic(diag.INCOMPLETE_CATALOG_METADATA, f"catalog relation {metadata.identity} {problem}", _origin(metadata)))
303
+ return None
304
+ target = _table(name)
305
+ if metadata.kind is DatabaseRelationKind.TABLE:
306
+ columns = [self._column_definition(column) for column in metadata.columns]
307
+ return exp.Create(kind="TABLE", this=exp.Schema(this=target, expressions=columns))
308
+ view = self._view(metadata)
309
+ if view is None:
310
+ return None
311
+ declaration = view.copy()
312
+ if isinstance(declaration.this, exp.Schema):
313
+ declaration.this.set("this", target)
314
+ else:
315
+ declaration.set("this", target)
316
+ return declaration
317
+
318
+ def _column_definition(self, column: DatabaseColumn) -> exp.ColumnDef:
319
+ """The column with its catalog type and collation, so the Program
320
+ compares and enumerates it as the database does; untyped if the type
321
+ does not parse."""
322
+ definition = exp.ColumnDef(this=exp.to_identifier(column.name))
323
+ if column.data_type is None:
324
+ return definition
325
+ try:
326
+ kind = exp.DataType.build(column.data_type, dialect=self.fetcher.type_dialect)
327
+ except SqlglotError:
328
+ return definition
329
+ definition.set("kind", kind)
330
+ if column.collation is not None:
331
+ collate = exp.CollateColumnConstraint(this=exp.to_identifier(column.collation))
332
+ definition.append("constraints", exp.ColumnConstraint(kind=collate))
333
+ return definition
334
+
335
+ def _view(self, metadata: DatabaseRelationMetadata) -> exp.Create | None:
336
+ """The view's definition with every Relation it reads named by canonical identity, memoized."""
337
+ if metadata.identity not in self.views:
338
+ self.views[metadata.identity] = self._fetch_view(metadata)
339
+ return self.views[metadata.identity]
340
+
341
+ def _fetch_view(self, metadata: DatabaseRelationMetadata) -> exp.Create | None:
342
+ try:
343
+ definition = self.fetcher.fetch_view_definition(metadata)
344
+ except DatabaseMetadataError as error:
345
+ self.diagnostics.append(Diagnostic(diag.CATALOG_METADATA_UNAVAILABLE, f"definition of view {metadata.identity} could not be read: {error}", _origin(metadata)))
346
+ return None
347
+ parsed = SqlParser(self.dialect).parse(definition.sql)
348
+ self.diagnostics.extend(parsed.diagnostics)
349
+ create = parsed.create_statements[0] if len(parsed.create_statements) == 1 and parsed.root_select is None else None
350
+ body = create.args.get("expression") if isinstance(create, exp.Create) and (create.kind or "").upper() == "VIEW" else None
351
+ if not isinstance(create, exp.Create) or not isinstance(body, exp.Query) or parsed.diagnostics:
352
+ self.diagnostics.append(Diagnostic(diag.INCOMPLETE_CATALOG_METADATA, f"definition of view {metadata.identity} is not one analyzable CREATE VIEW statement", _origin(metadata)))
353
+ return None
354
+ try:
355
+ tables = [source for scope in traverse_scope(body) for source in scope.sources.values() if isinstance(source, exp.Table)]
356
+ except SqlglotError as error:
357
+ self.diagnostics.append(Diagnostic(diag.INCOMPLETE_CATALOG_METADATA, f"definition of view {metadata.identity} could not be scoped: {error}", _origin(metadata)))
358
+ return None
359
+
360
+ # Names inside a view resolve in the view's own scope, so they are
361
+ # rewritten to identities before they join the Program's global names.
362
+ resolved = self._resolve(tuple(dict.fromkeys(_qualified_name(table) for table in tables)), metadata)
363
+ if None in resolved.values():
364
+ return None
365
+ # A column qualified by its table's schema (MySQL stores `db`.`t`.`c`)
366
+ # is qualified by the alias the table is given below.
367
+ columns = [column for column in body.find_all(exp.Column) if column.args.get("db")]
368
+ for table in tables:
369
+ target = resolved[_qualified_name(table).lower()]
370
+ assert target is not None
371
+ for column in columns:
372
+ if not table.alias and _qualified_name(table).lower().endswith(".".join((column.db, column.table)).lower()):
373
+ column.set("db", None)
374
+ column.set("catalog", None)
375
+ self.resolved[target.identity.lower()] = target
376
+ alias = table.alias or table.name
377
+ table.set("this", exp.to_identifier(target.name))
378
+ table.set("db", exp.to_identifier(target.schema))
379
+ table.set("catalog", exp.to_identifier(target.catalog))
380
+ table.set("alias", exp.TableAlias(this=exp.to_identifier(alias)))
381
+ return create
382
+
383
+
384
+ def _origin(metadata: DatabaseRelationMetadata) -> Origin:
385
+ return Origin(CATALOG, metadata.identity)
386
+
387
+
388
+ def _table(name: str) -> exp.Table:
389
+ *qualifiers, table = name.split(".")
390
+ catalog, db = ([None, None] + qualifiers)[-2:]
391
+ return exp.table_(table, db=db, catalog=catalog)
392
+
393
+
394
+ def _created_table(statement: Expr) -> exp.Table | None:
395
+ target = statement.this if isinstance(statement, exp.Create) else None
396
+ if isinstance(target, exp.Schema):
397
+ target = target.this
398
+ return target if isinstance(target, exp.Table) else None
399
+
400
+
401
+ def _qualified_name(table: exp.Table) -> str:
402
+ return ".".join(part for part in (table.catalog, table.db, table.name) if part)