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,326 @@
1
+ """The catalog boundary of any database with a standard `INFORMATION_SCHEMA`, over DB-API.
2
+
3
+ `InformationSchemaKnowledgeFetcher` resolves names and reads the standard
4
+ views (`TABLES`, `COLUMNS`, `TABLE_CONSTRAINTS`, `KEY_COLUMN_USAGE`,
5
+ `REFERENTIAL_CONSTRAINTS`, `VIEWS`) through any DB-API 2.0 connection; it
6
+ never imports a driver and never scans a table. A subclass per dialect
7
+ (`discovery/duckdb.py`, `postgres.py`, `mysql.py`, `redshift.py`, `snowflake.py`,
8
+ `bigquery.py`, `databricks.py`, `trino.py`) overrides only the hooks whose standard view
9
+ is missing or incomplete there, and the parameter style.
10
+
11
+ What it reports, and what it leaves out:
12
+
13
+ - Every column has the dialect's default collation: per-column and
14
+ per-table collations are not read (Postgres reports a nondeterministic one,
15
+ which would otherwise be taken as comparing by identity).
16
+ - Primary keys, unique constraints, unique indexes and foreign keys are
17
+ reported as declared; the catalog carries no per-constraint trust. Whether
18
+ they are Knowledge is the dialect's `DialectSemantics` decision, applied
19
+ by `DatabaseKnowledgeFetcher.bind`: where the dialect does not enforce them
20
+ they hold only under `trust_unenforced_keys`.
21
+ - A unique index with a `WHERE` or an expression key part is never a key.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Mapping, Sequence
27
+ from dataclasses import dataclass, replace
28
+ from typing import Any, Literal, Protocol
29
+
30
+ from sqlglot import exp
31
+
32
+ from sqlassert.discovery.fetcher import (
33
+ DatabaseColumn,
34
+ DatabaseForeignKey,
35
+ DatabaseKnowledgeFetcher,
36
+ DatabaseMetadataError,
37
+ DatabaseRelationKind,
38
+ DatabaseRelationMetadata,
39
+ DatabaseViewDefinition,
40
+ )
41
+
42
+
43
+ class Cursor(Protocol):
44
+ def execute(self, operation: str, parameters: Any = ..., /) -> Any: ...
45
+ def fetchall(self) -> Any: ...
46
+ def close(self) -> Any: ...
47
+
48
+
49
+ class Connection(Protocol):
50
+ def cursor(self) -> Cursor: ...
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class FoundRelation:
55
+ """A catalog Relation a name resolved to. `facts_hold` is False when its
56
+ keys and NOT NULLs do not cover every row it returns (a Postgres
57
+ inheritance parent, a foreign table): it is then columns only."""
58
+
59
+ catalog: str
60
+ schema: str
61
+ name: str
62
+ kind: DatabaseRelationKind
63
+ facts_hold: bool = True
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Key:
68
+ table: str
69
+ columns: tuple[str, ...]
70
+ primary: bool = False
71
+
72
+
73
+ type _Key = tuple[str, str, str]
74
+
75
+
76
+ class InformationSchemaKnowledgeFetcher(DatabaseKnowledgeFetcher):
77
+ """Standard `INFORMATION_SCHEMA` discovery; one subclass per dialect."""
78
+
79
+ # The DB-API parameter style of the dialect's drivers: `%s` or `?`.
80
+ paramstyle: Literal["format", "qmark"] = "format"
81
+ # One row: the current catalog and the schema unqualified names resolve in.
82
+ session_sql: str = "SELECT CURRENT_CATALOG, CURRENT_SCHEMA"
83
+ # Whether a name may be qualified by a catalog other than the current one.
84
+ cross_catalog: bool = False
85
+ # Whether names that differ only in case are distinct relations, an
86
+ # unquoted name folded to lower case (PostgreSQL). A name's quoting is not
87
+ # known here, so it denotes a relation spelled as it is or as it folds,
88
+ # and only when no other relation on its lookup path is spelled either way.
89
+ case_sensitive: bool = False
90
+
91
+ def __init__(self, connection: Connection):
92
+ self.connection = connection
93
+
94
+ # Hooks a dialect overrides where its standard views fall short.
95
+
96
+ def session(self, scope: DatabaseRelationMetadata | None) -> tuple[str, tuple[str, ...]]:
97
+ """The current catalog and the schemas an unqualified name is looked
98
+ up in, in order. Inside a view, its own catalog and schema."""
99
+ if scope is not None:
100
+ return scope.catalog, (scope.schema,)
101
+ (catalog, schema), = self._rows(self.session_sql)
102
+ return catalog, (schema,) if schema else ()
103
+
104
+ def information_schema(self, catalog: str, schema: str) -> str:
105
+ """Where the standard views of ``catalog``.``schema`` are."""
106
+ return "INFORMATION_SCHEMA"
107
+
108
+ def relations(self, catalog: str, schema: str, names: Sequence[str]) -> list[FoundRelation]:
109
+ """The Relations of ``schema`` whose lower-cased name is in ``names``
110
+ (lower-cased), matched case-insensitively."""
111
+ sql = (
112
+ f"SELECT TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM {self.information_schema(catalog, schema)}.TABLES "
113
+ f"WHERE LOWER(TABLE_SCHEMA) = %s AND LOWER(TABLE_NAME) IN ({placeholders(names)})"
114
+ )
115
+ return [
116
+ FoundRelation(found_catalog, found_schema, name, DatabaseRelationKind.VIEW if kind == "VIEW" else DatabaseRelationKind.TABLE)
117
+ for found_catalog, found_schema, name, kind in self._rows(sql, (schema.lower(), *names))
118
+ ]
119
+
120
+ def columns(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseColumn]]:
121
+ sql = (
122
+ f"SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, DATA_TYPE FROM {self.information_schema(catalog, schema)}.COLUMNS "
123
+ f"WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders(tables)}) ORDER BY TABLE_NAME, ORDINAL_POSITION"
124
+ )
125
+ return [
126
+ (table, DatabaseColumn(column, nullable == "YES", data_type))
127
+ for table, column, nullable, data_type in self._rows(sql, (schema, *tables))
128
+ ]
129
+
130
+ def keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[Key]:
131
+ """Primary keys, unique constraints and unique indexes over plain columns."""
132
+ where = self.information_schema(catalog, schema)
133
+ sql = f"""
134
+ SELECT c.TABLE_NAME, c.CONSTRAINT_NAME, c.CONSTRAINT_TYPE, k.COLUMN_NAME
135
+ FROM {where}.TABLE_CONSTRAINTS c JOIN {where}.KEY_COLUMN_USAGE k
136
+ ON k.CONSTRAINT_SCHEMA = c.CONSTRAINT_SCHEMA AND k.CONSTRAINT_NAME = c.CONSTRAINT_NAME
137
+ AND k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME
138
+ WHERE c.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE') AND c.TABLE_SCHEMA = %s AND c.TABLE_NAME IN ({placeholders(tables)})
139
+ ORDER BY c.TABLE_NAME, c.CONSTRAINT_NAME, k.ORDINAL_POSITION
140
+ """
141
+ grouped: dict[tuple[str, str], tuple[bool, list[str | None]]] = {}
142
+ for table, constraint, kind, column in self._rows(sql, (schema, *tables)):
143
+ grouped.setdefault((table, constraint), (kind == "PRIMARY KEY", []))[1].append(column)
144
+ return [key for (table, _), (primary, members) in grouped.items() if (key := key_over_columns(table, members, primary)) is not None]
145
+
146
+ def foreign_keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseForeignKey]]:
147
+ """Each foreign key's columns paired by position with the columns of
148
+ the unique constraint it references. One whose referenced columns do
149
+ not resolve to one table is left out."""
150
+ where = self.information_schema(catalog, schema)
151
+ sql = f"""
152
+ SELECT k.TABLE_NAME, k.CONSTRAINT_NAME, k.COLUMN_NAME, u.TABLE_CATALOG, u.TABLE_SCHEMA, u.TABLE_NAME, u.COLUMN_NAME
153
+ FROM {where}.REFERENTIAL_CONSTRAINTS r
154
+ JOIN {where}.KEY_COLUMN_USAGE k
155
+ ON k.CONSTRAINT_CATALOG = r.CONSTRAINT_CATALOG AND k.CONSTRAINT_SCHEMA = r.CONSTRAINT_SCHEMA AND k.CONSTRAINT_NAME = r.CONSTRAINT_NAME
156
+ LEFT JOIN {where}.KEY_COLUMN_USAGE u
157
+ ON u.CONSTRAINT_CATALOG = r.UNIQUE_CONSTRAINT_CATALOG AND u.CONSTRAINT_SCHEMA = r.UNIQUE_CONSTRAINT_SCHEMA
158
+ AND u.CONSTRAINT_NAME = r.UNIQUE_CONSTRAINT_NAME AND u.ORDINAL_POSITION = k.POSITION_IN_UNIQUE_CONSTRAINT
159
+ WHERE k.TABLE_SCHEMA = %s AND k.TABLE_NAME IN ({placeholders(tables)})
160
+ ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION
161
+ """
162
+ return foreign_keys_from_pairs(self._rows(sql, (schema, *tables)))
163
+
164
+ def view_definition(self, relation: DatabaseRelationMetadata) -> str | None:
165
+ """The view's stored text: its body, or a whole `CREATE VIEW`."""
166
+ sql = f"SELECT VIEW_DEFINITION FROM {self.information_schema(relation.catalog, relation.schema)}.VIEWS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s"
167
+ rows = self._rows(sql, (relation.schema, relation.name))
168
+ return rows[0][0] if rows else None
169
+
170
+ # The assembly every dialect shares.
171
+
172
+ def fetch_relation_metadata(
173
+ self, names: tuple[str, ...], scope: DatabaseRelationMetadata | None = None
174
+ ) -> Mapping[str, DatabaseRelationMetadata]:
175
+ catalog, path = self.session(scope)
176
+ candidates = {name: self.candidates(name, catalog, path) for name in names}
177
+ groups: dict[tuple[str, str], tuple[str, str, set[str]]] = {}
178
+ for options in candidates.values():
179
+ for candidate_catalog, schema, table in options:
180
+ groups.setdefault((candidate_catalog.lower(), schema.lower()), (candidate_catalog, schema, set()))[2].add(table.lower())
181
+ found: dict[_Key, list[FoundRelation]] = {}
182
+ for group_catalog, schema, tables in groups.values():
183
+ for relation in self.relations(group_catalog, schema, sorted(tables)):
184
+ listed = found.setdefault((relation.catalog.lower(), relation.schema.lower(), relation.name.lower()), [])
185
+ if relation not in listed:
186
+ listed.append(relation)
187
+ resolved: dict[str, FoundRelation] = {}
188
+ for name, options in candidates.items():
189
+ match = self._match(options, found)
190
+ if match is not None:
191
+ resolved[name] = match
192
+
193
+ tables: dict[tuple[str, str], set[str]] = {}
194
+ for relation in resolved.values():
195
+ if relation.kind is DatabaseRelationKind.TABLE:
196
+ tables.setdefault((relation.catalog, relation.schema), set()).add(relation.name)
197
+ columns: dict[_Key, list[DatabaseColumn]] = {}
198
+ keys: dict[_Key, list[Key]] = {}
199
+ foreign_keys: dict[_Key, list[DatabaseForeignKey]] = {}
200
+ for (table_catalog, schema), group in tables.items():
201
+ listed = sorted(group)
202
+ for table, column in self.columns(table_catalog, schema, listed):
203
+ columns.setdefault((table_catalog, schema, table), []).append(column)
204
+ for key in self.keys(table_catalog, schema, listed):
205
+ keys.setdefault((table_catalog, schema, key.table), []).append(key)
206
+ for table, foreign_key in self.foreign_keys(table_catalog, schema, listed):
207
+ foreign_keys.setdefault((table_catalog, schema, table), []).append(foreign_key)
208
+
209
+ metadata: dict[str, DatabaseRelationMetadata] = {}
210
+ for name, relation in resolved.items():
211
+ path_key = (relation.catalog, relation.schema, relation.name)
212
+ if relation.kind is DatabaseRelationKind.VIEW:
213
+ metadata[name] = DatabaseRelationMetadata(*path_key, DatabaseRelationKind.VIEW)
214
+ continue
215
+ own = tuple(columns.get(path_key, ()))
216
+ if not relation.facts_hold:
217
+ own = tuple(replace(column, nullable=True) for column in own)
218
+ metadata[name] = DatabaseRelationMetadata(*path_key, DatabaseRelationKind.TABLE, own)
219
+ continue
220
+ table_keys = keys.get(path_key, [])
221
+ metadata[name] = DatabaseRelationMetadata(
222
+ *path_key, DatabaseRelationKind.TABLE, own,
223
+ primary_key=next((key.columns for key in table_keys if key.primary), ()),
224
+ unique_sets=tuple(key.columns for key in table_keys if not key.primary),
225
+ foreign_keys=tuple(foreign_keys.get(path_key, ())),
226
+ )
227
+ return metadata
228
+
229
+ def fetch_view_definition(self, relation: DatabaseRelationMetadata) -> DatabaseViewDefinition:
230
+ text = self.view_definition(relation)
231
+ if not text or not text.strip():
232
+ # Hidden from a reader who does not own the view, in most dialects.
233
+ raise DatabaseMetadataError(f"definition of view {relation.identity} is not readable")
234
+ text = text.strip().rstrip(";")
235
+ if text.upper().startswith("CREATE"):
236
+ return DatabaseViewDefinition(sql=text)
237
+ name = exp.to_identifier(relation.name, quoted=True).sql(dialect=self.type_dialect)
238
+ return DatabaseViewDefinition(sql=f"CREATE VIEW {name} AS {text}")
239
+
240
+ def _match(self, options: list[tuple[str, str, str]], found: Mapping[_Key, list[FoundRelation]]) -> FoundRelation | None:
241
+ """The relation the first of ``options`` with one names, or None when
242
+ a name cannot tell the relations it may denote apart."""
243
+ if not self.case_sensitive:
244
+ # Names that differ only in case cannot be told apart.
245
+ listed = next((found[key] for option in options if (key := _lower(option)) in found), [])
246
+ return listed[0] if len(listed) == 1 else None
247
+ spelled = [
248
+ [
249
+ relation for relation in found.get(_lower(option), ())
250
+ if relation.schema in {schema, schema.lower()} and relation.name in {table, table.lower()}
251
+ ]
252
+ for option in options for _, schema, table in [option]
253
+ ]
254
+ if len({relation.name for listed in spelled for relation in listed}) > 1:
255
+ return None # `"T"` and `t` are both on the path: whether the name was quoted decides.
256
+ listed = next((listed for listed in spelled if listed), [])
257
+ return listed[0] if len(listed) == 1 else None
258
+
259
+ def candidates(self, name: str, catalog: str, path: tuple[str, ...]) -> list[tuple[str, str, str]]:
260
+ """Every (catalog, schema, table) ``name`` may denote, in lookup order."""
261
+ match name.split("."):
262
+ case [table]:
263
+ return [(catalog, schema, table) for schema in path]
264
+ case [schema, table]:
265
+ return [(catalog, schema, table)]
266
+ case [named, schema, table] if named.lower() == catalog.lower():
267
+ return [(catalog, schema, table)]
268
+ case [named, schema, table] if self.cross_catalog:
269
+ return [(named, schema, table)]
270
+ case _:
271
+ return []
272
+
273
+ def _rows(self, sql: str, parameters: tuple[Any, ...] = ()) -> list[tuple[Any, ...]]:
274
+ """The rows of one catalog query. Any driver error is a
275
+ DatabaseMetadataError: DB-API drivers share no exception base class."""
276
+ if self.paramstyle == "qmark":
277
+ sql = sql.replace("%s", "?")
278
+ try:
279
+ cursor = self.connection.cursor()
280
+ try:
281
+ cursor.execute(sql, parameters)
282
+ return [tuple(row) for row in cursor.fetchall()]
283
+ finally:
284
+ cursor.close()
285
+ except Exception as error:
286
+ raise DatabaseMetadataError(str(error)) from error
287
+
288
+
289
+ def foreign_keys_from_pairs(rows: Sequence[tuple[Any, ...]]) -> list[tuple[str, DatabaseForeignKey]]:
290
+ """Foreign keys from rows of (table, constraint, column, referenced
291
+ catalog, schema, table, column), ordered by constraint and position. One
292
+ whose referenced columns are missing or span tables is left out: a
293
+ foreign key over part of its columns does not hold."""
294
+ grouped: dict[tuple[str, str], list[tuple[Any, ...]]] = {}
295
+ for table, constraint, *pair in rows:
296
+ grouped.setdefault((table, constraint), []).append(tuple(pair))
297
+ found: list[tuple[str, DatabaseForeignKey]] = []
298
+ for (table, _), pairs in grouped.items():
299
+ targets = {(catalog, schema, referenced) for _, catalog, schema, referenced, _ in pairs}
300
+ if len(targets) != 1 or any(part is None for pair in pairs for part in pair):
301
+ continue
302
+ (catalog, schema, referenced), = targets
303
+ found.append((table, DatabaseForeignKey(
304
+ tuple(pair[0] for pair in pairs), f"{catalog}.{schema}.{referenced}", tuple(pair[4] for pair in pairs),
305
+ )))
306
+ return found
307
+
308
+
309
+ def key_over_columns(table: str, members: Sequence[str | None], primary: bool = False) -> Key | None:
310
+ """A key over plain columns; None when a part is an expression (no column name)."""
311
+ if not members or any(member is None for member in members):
312
+ return None
313
+ return Key(table, tuple(member for member in members if member is not None), primary)
314
+
315
+
316
+ def quoted(identifier: str, quote: str = '"') -> str:
317
+ return quote + identifier.replace(quote, quote * 2) + quote
318
+
319
+
320
+ def placeholders(values: Sequence[Any]) -> str:
321
+ """`%s, %s, ...`: one placeholder per value, for an IN list."""
322
+ return ", ".join(["%s"] * len(values))
323
+
324
+
325
+ def _lower(path: tuple[str, str, str]) -> _Key:
326
+ return (path[0].lower(), path[1].lower(), path[2].lower())
@@ -0,0 +1,70 @@
1
+ """MySQL: the standard discovery, with `information_schema` columns the standard lacks.
2
+
3
+ Over any DB-API connection (PyMySQL, mysqlclient, mysql-connector). Overrides:
4
+
5
+ - columns: `COLUMN_TYPE`, which keeps an ENUM's members, not `DATA_TYPE`;
6
+ - keys: `STATISTICS`, the unique indexes. `KEY_COLUMN_USAGE` leaves out a
7
+ functional key part, which would make an index over `(a, (lower(b)))` look
8
+ like a key over `a`; an index with one is skipped. A prefix index is unique
9
+ on the prefix, so on the column too;
10
+ - foreign keys: `KEY_COLUMN_USAGE`'s `REFERENCED_*` columns, since every
11
+ primary key is named `PRIMARY`, which the standard join cannot tell apart.
12
+ Only engines that enforce foreign keys (InnoDB, NDB) keep them in the
13
+ catalog. `foreign_key_checks = 0` lets a session load rows that violate
14
+ one, which no catalog records.
15
+
16
+ MySQL has one catalog, which `information_schema` names `def`. A stored view
17
+ definition qualifies every table it reads; an unqualified name in the Program
18
+ is in the current database.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Sequence
24
+
25
+ from sqlassert.discovery.fetcher import DatabaseColumn, DatabaseForeignKey
26
+ from sqlassert.discovery.information_schema import (
27
+ InformationSchemaKnowledgeFetcher,
28
+ Key,
29
+ foreign_keys_from_pairs,
30
+ key_over_columns,
31
+ placeholders,
32
+ )
33
+
34
+
35
+ class MySQLKnowledgeFetcher(InformationSchemaKnowledgeFetcher):
36
+ type_dialect = "mysql"
37
+ session_sql = "SELECT 'def', DATABASE()"
38
+
39
+ def columns(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseColumn]]:
40
+ sql = (
41
+ "SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_TYPE FROM information_schema.COLUMNS "
42
+ f"WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders(tables)}) ORDER BY TABLE_NAME, ORDINAL_POSITION"
43
+ )
44
+ return [
45
+ (table, DatabaseColumn(column, nullable == "YES", data_type))
46
+ for table, column, nullable, data_type in self._rows(sql, (schema, *tables))
47
+ ]
48
+
49
+ def keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[Key]:
50
+ sql = (
51
+ "SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS "
52
+ f"WHERE NON_UNIQUE = 0 AND TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders(tables)}) "
53
+ "ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX"
54
+ )
55
+ indexes: dict[tuple[str, str], list[str | None]] = {}
56
+ for table, index, column in self._rows(sql, (schema, *tables)):
57
+ indexes.setdefault((table, index), []).append(column)
58
+ return [
59
+ key for (table, index), members in indexes.items()
60
+ if (key := key_over_columns(table, members, index == "PRIMARY")) is not None
61
+ ]
62
+
63
+ def foreign_keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseForeignKey]]:
64
+ sql = (
65
+ "SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, 'def', REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME "
66
+ "FROM information_schema.KEY_COLUMN_USAGE "
67
+ f"WHERE REFERENCED_TABLE_NAME IS NOT NULL AND TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders(tables)}) "
68
+ "ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION"
69
+ )
70
+ return foreign_keys_from_pairs(self._rows(sql, (schema, *tables)))
@@ -0,0 +1,143 @@
1
+ """PostgreSQL: the standard discovery, with `pg_catalog` where `information_schema` falls short.
2
+
3
+ PostgreSQL's `information_schema` lists a constraint only to a role that owns
4
+ its table or holds a privilege on it other than SELECT, and a view's text only
5
+ to its owner, so a read-only role would see no keys. Every hook here reads
6
+ `pg_catalog` instead, over any DB-API connection (psycopg, psycopg2).
7
+
8
+ Only constraints that hold on every row are reported: validated, enforced
9
+ (PostgreSQL 18 has NOT ENFORCED), not DEFERRABLE, and, for a foreign key,
10
+ onto a table without row-level security, which may hide a referenced row
11
+ from the reader. Unique indexes no constraint owns count, over their key
12
+ columns (not INCLUDE ones), when valid, immediate, with no `WHERE` and no
13
+ expression. A table other tables inherit from returns their rows too, which
14
+ its own keys and NOT NULLs do not cover, so it is columns only, as are
15
+ foreign tables, whose constraints are never checked. A NOT NULL added NOT
16
+ VALID does not count. A column's collation is reported only when it is
17
+ nondeterministic: the dialect's default compares by identity, and such a
18
+ collation does not.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Sequence
24
+
25
+ from sqlassert.discovery.fetcher import (
26
+ DatabaseColumn,
27
+ DatabaseForeignKey,
28
+ DatabaseRelationKind,
29
+ DatabaseRelationMetadata,
30
+ )
31
+ from sqlassert.discovery.information_schema import FoundRelation, InformationSchemaKnowledgeFetcher, Key
32
+
33
+ _SESSION = "SELECT current_database(), current_schemas(true), current_setting('server_version_num')::int"
34
+
35
+ _RELATION = "pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace"
36
+ _TABLES = "n.nspname = %s AND c.relname = ANY(%s::text[])"
37
+
38
+ # 'r' table, 'p' partitioned table, 'v' view, 'm' materialized view, 'f' foreign table.
39
+ _RELATIONS = f"""
40
+ SELECT current_database(), n.nspname, c.relname, c.relkind, c.relkind = 'r' AND c.relhassubclass
41
+ FROM {_RELATION}
42
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f') AND lower(n.nspname) = %s AND lower(c.relname) = ANY(%s::text[])
43
+ """
44
+
45
+ _COLUMNS = f"""
46
+ SELECT c.relname, a.attname,
47
+ NOT a.attnotnull OR EXISTS (
48
+ SELECT 1 FROM pg_catalog.pg_constraint k
49
+ WHERE k.conrelid = c.oid AND k.contype = 'n' AND NOT k.convalidated AND a.attnum = ANY(k.conkey)
50
+ ),
51
+ pg_catalog.format_type(a.atttypid, a.atttypmod),
52
+ CASE WHEN a.attcollation = 0 OR co.collisdeterministic THEN NULL ELSE co.collname END
53
+ FROM pg_catalog.pg_attribute a
54
+ JOIN {_RELATION} ON c.oid = a.attrelid
55
+ LEFT JOIN pg_catalog.pg_collation co ON co.oid = a.attcollation
56
+ WHERE a.attnum > 0 AND NOT a.attisdropped AND {_TABLES}
57
+ ORDER BY c.relname, a.attnum
58
+ """
59
+
60
+
61
+ def _names(keys: str, relation: str) -> str:
62
+ return f"""ARRAY(
63
+ SELECT a.attname FROM unnest({keys}) WITH ORDINALITY AS key(attnum, position)
64
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = {relation} AND a.attnum = key.attnum
65
+ ORDER BY key.position
66
+ )"""
67
+
68
+
69
+ _CONSTRAINTS = f"""
70
+ SELECT c.relname, k.contype, {_names("k.conkey", "k.conrelid")}, current_database(), rn.nspname, r.relname, {_names("k.confkey", "k.confrelid")}
71
+ FROM pg_catalog.pg_constraint k
72
+ JOIN {_RELATION} ON c.oid = k.conrelid
73
+ LEFT JOIN pg_catalog.pg_class r ON r.oid = k.confrelid
74
+ LEFT JOIN pg_catalog.pg_namespace rn ON rn.oid = r.relnamespace
75
+ WHERE k.contype IN ({{kinds}}) AND k.convalidated AND NOT k.condeferrable {{enforced}}
76
+ AND NOT (k.contype = 'f' AND r.relrowsecurity) AND {_TABLES}
77
+ ORDER BY c.relname, k.conname
78
+ """
79
+
80
+ _UNIQUE_INDEXES = f"""
81
+ SELECT c.relname, {_names("(i.indkey::int2[])[0:i.indnkeyatts - 1]", "i.indrelid")}
82
+ FROM pg_catalog.pg_index i
83
+ JOIN {_RELATION} ON c.oid = i.indrelid
84
+ WHERE i.indisunique AND i.indisvalid AND i.indimmediate AND i.indpred IS NULL AND i.indexprs IS NULL
85
+ AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint k WHERE k.conindid = i.indexrelid) AND {_TABLES}
86
+ ORDER BY c.relname, i.indexrelid
87
+ """
88
+
89
+ VIEW = f"""
90
+ SELECT pg_catalog.pg_get_viewdef(c.oid, true)
91
+ FROM {_RELATION}
92
+ WHERE n.nspname = %s AND c.relname = %s AND c.relkind = 'v'
93
+ """
94
+
95
+
96
+ class PostgresKnowledgeFetcher(InformationSchemaKnowledgeFetcher):
97
+ type_dialect = "postgres"
98
+ case_sensitive = True
99
+ _enforced = False
100
+
101
+ def session(self, scope: DatabaseRelationMetadata | None) -> tuple[str, tuple[str, ...]]:
102
+ # A stored view definition names relations as the session's search path
103
+ # sees them (pg_get_viewdef qualifies the rest), so `scope` changes nothing.
104
+ (database, search_path, version), = self._rows(_SESSION)
105
+ self._enforced = version >= 180000
106
+ return database, tuple(search_path)
107
+
108
+ def relations(self, catalog: str, schema: str, names: Sequence[str]) -> list[FoundRelation]:
109
+ return [
110
+ FoundRelation(
111
+ database, found_schema, name, DatabaseRelationKind.VIEW if kind == "v" else DatabaseRelationKind.TABLE,
112
+ facts_hold=not inherited and kind != "f",
113
+ )
114
+ for database, found_schema, name, kind, inherited in self._rows(_RELATIONS, (schema.lower(), list(names)))
115
+ ]
116
+
117
+ def columns(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseColumn]]:
118
+ return [
119
+ (table, DatabaseColumn(column, nullable, data_type, collation))
120
+ for table, column, nullable, data_type, collation in self._rows(_COLUMNS, (schema, list(tables)))
121
+ ]
122
+
123
+ def keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[Key]:
124
+ keys = [
125
+ Key(table, tuple(members), kind == "p")
126
+ for table, kind, members, *_ in self._rows(self._constraints("'p', 'u'"), (schema, list(tables)))
127
+ ]
128
+ keys.extend(Key(table, tuple(members)) for table, members in self._rows(_UNIQUE_INDEXES, (schema, list(tables))))
129
+ return keys
130
+
131
+ def foreign_keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseForeignKey]]:
132
+ return [
133
+ (table, DatabaseForeignKey(tuple(members), f"{database}.{referenced_schema}.{referenced}", tuple(referenced_members)))
134
+ for table, _, members, database, referenced_schema, referenced, referenced_members
135
+ in self._rows(self._constraints("'f'"), (schema, list(tables)))
136
+ ]
137
+
138
+ def view_definition(self, relation: DatabaseRelationMetadata) -> str | None:
139
+ rows = self._rows(VIEW, (relation.schema, relation.name))
140
+ return rows[0][0] if rows else None
141
+
142
+ def _constraints(self, kinds: str) -> str:
143
+ return _CONSTRAINTS.format(kinds=kinds, enforced="AND k.conenforced" if self._enforced else "")
@@ -0,0 +1,36 @@
1
+ """Amazon Redshift: the standard discovery, over any DB-API connection (redshift_connector, psycopg2).
2
+
3
+ Redshift keeps PostgreSQL 8's `information_schema` and has no indexes, so
4
+ there are no unique indexes to read. Its keys are not enforced: they hold
5
+ only under `trust_unenforced_keys` (`dialects.py`). Overrides:
6
+
7
+ - session: `current_database()` and `current_schema()`. An unqualified name
8
+ is looked up in the current schema only, so a table found later on the
9
+ search path is reported missing, never mistaken;
10
+ - view definitions: `pg_get_viewdef`, as PostgreSQL, since
11
+ `information_schema.views` shows a view's text to its owner only. A view
12
+ reads the tables it was created over, so its names resolve as the
13
+ Program's do.
14
+
15
+ Like PostgreSQL's, Redshift's `information_schema` may list constraints only
16
+ for tables the role holds a privilege on other than SELECT; a read-only role
17
+ then sees no keys, which loses proofs, never soundness.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from sqlassert.discovery.fetcher import DatabaseRelationMetadata
23
+ from sqlassert.discovery.information_schema import InformationSchemaKnowledgeFetcher
24
+ from sqlassert.discovery.postgres import VIEW
25
+
26
+
27
+ class RedshiftKnowledgeFetcher(InformationSchemaKnowledgeFetcher):
28
+ type_dialect = "redshift"
29
+ session_sql = "SELECT current_database(), current_schema()"
30
+
31
+ def session(self, scope: DatabaseRelationMetadata | None) -> tuple[str, tuple[str, ...]]:
32
+ return super().session(None)
33
+
34
+ def view_definition(self, relation: DatabaseRelationMetadata) -> str | None:
35
+ rows = self._rows(VIEW, (relation.schema, relation.name))
36
+ return rows[0][0] if rows else None
@@ -0,0 +1,63 @@
1
+ """Snowflake: the standard discovery, with `SHOW ... KEYS` for the keys.
2
+
3
+ Over `snowflake-connector-python` (`%s` parameters). Snowflake's
4
+ `INFORMATION_SCHEMA` has `TABLE_CONSTRAINTS` and `REFERENTIAL_CONSTRAINTS`
5
+ but no `KEY_COLUMN_USAGE`, so a constraint's columns come from `SHOW PRIMARY
6
+ KEYS`, `SHOW UNIQUE KEYS` and `SHOW IMPORTED KEYS` instead. Keys are not
7
+ enforced: they hold only under `trust_unenforced_keys` (`dialects.py`); the
8
+ `rely` column those commands return is not read. `VIEWS.VIEW_DEFINITION` is
9
+ the whole `CREATE VIEW`, and names inside it resolve in the view's own
10
+ database and schema. Names qualified by another database are not looked up.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+ from typing import Any
17
+
18
+ from sqlassert.discovery.fetcher import DatabaseForeignKey
19
+ from sqlassert.discovery.information_schema import (
20
+ InformationSchemaKnowledgeFetcher,
21
+ Key,
22
+ foreign_keys_from_pairs,
23
+ key_over_columns,
24
+ quoted,
25
+ )
26
+
27
+ # Positions in the rows of SHOW PRIMARY KEYS and SHOW UNIQUE KEYS.
28
+ _KEY_TABLE, _KEY_COLUMN, _KEY_SEQUENCE, _KEY_NAME = 3, 4, 5, 6
29
+ # Positions in the rows of SHOW IMPORTED KEYS.
30
+ _PK_CATALOG, _PK_SCHEMA, _PK_TABLE, _PK_COLUMN, _FK_TABLE, _FK_COLUMN, _FK_SEQUENCE, _FK_NAME = 1, 2, 3, 4, 7, 8, 9, 12
31
+
32
+
33
+ class SnowflakeKnowledgeFetcher(InformationSchemaKnowledgeFetcher):
34
+ type_dialect = "snowflake"
35
+ session_sql = "SELECT CURRENT_DATABASE(), CURRENT_SCHEMA()"
36
+
37
+ def keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[Key]:
38
+ keys: list[Key] = []
39
+ for command, primary in (("PRIMARY KEYS", True), ("UNIQUE KEYS", False)):
40
+ grouped: dict[tuple[str, str], list[tuple[int, str | None]]] = {}
41
+ for row in self._show(command, catalog, schema, tables, _KEY_TABLE):
42
+ grouped.setdefault((row[_KEY_TABLE], row[_KEY_NAME]), []).append((int(row[_KEY_SEQUENCE]), row[_KEY_COLUMN]))
43
+ for (table, _), parts in grouped.items():
44
+ key = key_over_columns(table, [column for _, column in sorted(parts)], primary)
45
+ if key is not None:
46
+ keys.append(key)
47
+ return keys
48
+
49
+ def foreign_keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseForeignKey]]:
50
+ rows = sorted(
51
+ self._show("IMPORTED KEYS", catalog, schema, tables, _FK_TABLE),
52
+ key=lambda row: (row[_FK_TABLE], row[_FK_NAME], int(row[_FK_SEQUENCE])),
53
+ )
54
+ return foreign_keys_from_pairs([
55
+ (row[_FK_TABLE], row[_FK_NAME], row[_FK_COLUMN], row[_PK_CATALOG], row[_PK_SCHEMA], row[_PK_TABLE], row[_PK_COLUMN])
56
+ for row in rows
57
+ ])
58
+
59
+ def _show(self, command: str, catalog: str, schema: str, tables: Sequence[str], table_position: int) -> list[tuple[Any, ...]]:
60
+ """The rows of `SHOW <command> IN SCHEMA` for ``tables``. SHOW takes no parameters."""
61
+ wanted = set(tables)
62
+ rows = self._rows(f"SHOW {command} IN SCHEMA {quoted(catalog)}.{quoted(schema)}")
63
+ return [row for row in rows if row[table_position] in wanted]