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
|
@@ -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]
|