sqlassert 0.1.2__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. sqlassert/__init__.py +34 -9
  2. sqlassert/__main__.py +138 -0
  3. sqlassert/check.py +444 -0
  4. sqlassert/diagnostics.py +57 -0
  5. sqlassert/dialects.py +211 -0
  6. sqlassert/discovery/__init__.py +30 -0
  7. sqlassert/discovery/bigquery.py +71 -0
  8. sqlassert/discovery/databricks.py +18 -0
  9. sqlassert/discovery/duckdb.py +163 -0
  10. sqlassert/discovery/fetcher.py +402 -0
  11. sqlassert/discovery/information_schema.py +326 -0
  12. sqlassert/discovery/mysql.py +70 -0
  13. sqlassert/discovery/postgres.py +143 -0
  14. sqlassert/discovery/redshift.py +36 -0
  15. sqlassert/discovery/snowflake.py +63 -0
  16. sqlassert/discovery/trino.py +37 -0
  17. sqlassert/improvements.py +528 -0
  18. sqlassert/ir/__init__.py +56 -0
  19. sqlassert/ir/convert.py +2377 -0
  20. sqlassert/ir/model.py +676 -0
  21. sqlassert/main.py +57 -0
  22. sqlassert/properties.py +121 -0
  23. sqlassert/provenance.py +73 -0
  24. sqlassert/python_engine.py +299 -0
  25. sqlassert/reporting.py +102 -0
  26. sqlassert/rules/STYLEGUIDE.md +19 -0
  27. sqlassert/rules/__init__.py +11 -0
  28. sqlassert/rules/column_set.py +34 -0
  29. sqlassert/rules/domain.py +376 -0
  30. sqlassert/rules/equality.py +233 -0
  31. sqlassert/rules/exactly_one.py +75 -0
  32. sqlassert/rules/foreign_key.py +316 -0
  33. sqlassert/rules/join_cardinality.py +94 -0
  34. sqlassert/rules/nullability.py +418 -0
  35. sqlassert/rules/uniqueness.py +601 -0
  36. sqlassert/smells.py +306 -0
  37. sqlassert/sql_parse.py +974 -0
  38. sqlassert/suggest.py +339 -0
  39. sqlassert-0.3.1.dist-info/METADATA +725 -0
  40. sqlassert-0.3.1.dist-info/RECORD +44 -0
  41. {sqlassert-0.1.2.dist-info → sqlassert-0.3.1.dist-info}/WHEEL +1 -1
  42. sqlassert-0.3.1.dist-info/entry_points.txt +2 -0
  43. sqlassert-0.3.1.dist-info/licenses/LICENSE +21 -0
  44. sqlassert/unique.py +0 -605
  45. sqlassert-0.1.2.dist-info/METADATA +0 -173
  46. sqlassert-0.1.2.dist-info/RECORD +0 -6
  47. {sqlassert-0.1.2.dist-info → sqlassert-0.3.1.dist-info}/top_level.txt +0 -0
sqlassert/dialects.py ADDED
@@ -0,0 +1,211 @@
1
+ """What each SQL dialect means by the constructs the analysis depends on.
2
+
3
+ One `DialectSemantics` per dialect name (SQLGlot's), read by `ir/convert.py`
4
+ while it lowers SQL: which Builtins raise an error rather than return NULL,
5
+ which text compares by identity, what `||` and `concat()` do with NULL, and
6
+ which constraints the database enforces. A dialect not listed gets
7
+ `DialectSemantics()`, whose arithmetic, collation and `||` are the cautious
8
+ choice; only its keys are taken as enforced, as before dialects were listed.
9
+
10
+ Every flag is a claim that holds under all of that dialect's settings, since
11
+ the SQL alone does not say which are in force (ADR 0005): MySQL's `||` is OR
12
+ or concatenation depending on `sql_mode`, so it is neither; Spark's
13
+ arithmetic raises or returns NULL depending on ANSI mode, so it may return
14
+ NULL. `docs/dialects.md` argues each entry and says how soundness is judged
15
+ without DuckDB; `docs/dialect-provenance.json` records, per fact, the engine
16
+ versions it was certified on or the vendor doc it rests on
17
+ (`scripts/certify_dialects.py`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import Literal
24
+
25
+ # How a dialect treats a declared key (PRIMARY KEY, UNIQUE) or foreign key:
26
+ # enforced; informational, trusted when marked `RELY`; or informational only.
27
+ type KeyTrust = Literal["enforced", "rely", "unenforced"]
28
+
29
+ # What `a || b` is: concatenation whose NULL operand makes it NULL ("strict"),
30
+ # concatenation that may skip a NULL operand ("lenient", Postgres arrays), or
31
+ # something whose meaning a setting decides ("ambiguous", MySQL); None when
32
+ # it is not modelled.
33
+ type PipesMeaning = Literal["strict", "lenient", "ambiguous"] | None
34
+
35
+ # What `concat(a, ...)` does with NULL: makes the result NULL ("strict"), or
36
+ # skips it, so the result is never NULL ("skips-null"); None when not modelled.
37
+ type ConcatMeaning = Literal["strict", "skips-null"] | None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DialectSemantics:
42
+ # `+`, `-`, `*`, unary `-` and CAST raise an error on overflow or a bad
43
+ # value rather than wrapping, saturating, or returning NULL.
44
+ checked_arithmetic: bool = False
45
+ # `/`, integer division and `%` by zero raise an error rather than return NULL.
46
+ checked_division: bool = False
47
+ # An implicit cast that fails raises an error, so a comparison, LIKE,
48
+ # BETWEEN, NOT or bitwise operator of non-null operands is never NULL.
49
+ casts_raise: bool = True
50
+ # VARCHAR and TEXT with no COLLATE compare by identity under the engine's
51
+ # shipped default collation. A setting that changes that default (DuckDB's
52
+ # `default_collation`) is out of scope; text with any COLLATE is never
53
+ # taken to compare by identity (docs/dialects.md).
54
+ text_is_binary: bool = False
55
+ # A DECIMAL declared without a scale keeps each value's own scale, so
56
+ # equal values (1.0, 1.00) may differ: neither identity nor exact.
57
+ unscaled_decimal: bool = False
58
+ # A TIMESTAMP WITH TIME ZONE (SQLGlot's TIMESTAMPTZ: MySQL's, BigQuery's
59
+ # and Spark's TIMESTAMP too) holds an instant only, so equal values are
60
+ # identical. Not where it keeps the zone it was given (Trino, Snowflake's
61
+ # TIMESTAMP_TZ, Oracle): equal instants in two zones differ as text.
62
+ zoned_timestamp_is_instant: bool = False
63
+ pipes: PipesMeaning = None
64
+ concat: ConcatMeaning = None
65
+ # What `concat_ws(sep, a, ...)` does with a NULL value (a NULL separator
66
+ # makes it NULL either way); None where it does not exist or is not modelled.
67
+ concat_ws: ConcatMeaning = None
68
+ unique_keys: KeyTrust = "enforced"
69
+ foreign_keys: KeyTrust = "enforced"
70
+ # A PRIMARY KEY column is NOT NULL.
71
+ primary_key_not_null: bool = True
72
+ # A column-level `REFERENCES` is enforced (MySQL before 9.0 parses and ignores it).
73
+ inline_references: bool = True
74
+ # The table ENGINEs that enforce foreign keys (lower-cased), None for every table.
75
+ foreign_key_engines: frozenset[str] | None = None
76
+ # An ENUM's members lose their trailing spaces (MySQL). An ENUM holds only
77
+ # its members: MySQL's '' for any other value, outside strict mode or
78
+ # under INSERT IGNORE, is out of scope (docs/dialects.md).
79
+ enum_strips_trailing_spaces: bool = False
80
+ # A zero-length string is NULL (Oracle): the literal '' is NULL.
81
+ empty_string_is_null: bool = False
82
+ # `EXCEPT ALL` and `INTERSECT ALL` exist. Only gates suggesting them, so
83
+ # it is no soundness claim and is not certified: a wrong True suggests SQL
84
+ # the engine rejects, never a wrong proof.
85
+ set_operations_all: bool = False
86
+
87
+ @property
88
+ def rely(self) -> bool:
89
+ """Whether the dialect marks an informational constraint trusted with `RELY`."""
90
+ return "rely" in (self.unique_keys, self.foreign_keys)
91
+
92
+
93
+ _SPARK = DialectSemantics(
94
+ # ANSI mode decides whether overflow, division by zero and a bad cast
95
+ # raise or return NULL; it is on by default only in recent versions.
96
+ casts_raise=False,
97
+ zoned_timestamp_is_instant=True,
98
+ pipes="strict",
99
+ concat="strict",
100
+ concat_ws="skips-null",
101
+ unique_keys="rely",
102
+ foreign_keys="rely",
103
+ set_operations_all=True,
104
+ )
105
+
106
+ _SEMANTICS: dict[str, DialectSemantics] = {
107
+ "duckdb": DialectSemantics(
108
+ checked_arithmetic=True,
109
+ text_is_binary=True,
110
+ zoned_timestamp_is_instant=True,
111
+ pipes="strict",
112
+ concat="skips-null",
113
+ concat_ws="skips-null",
114
+ set_operations_all=True,
115
+ ),
116
+ "postgres": DialectSemantics(
117
+ checked_arithmetic=True,
118
+ checked_division=True,
119
+ text_is_binary=True,
120
+ unscaled_decimal=True,
121
+ zoned_timestamp_is_instant=True,
122
+ pipes="lenient", # `ARRAY[1] || NULL` is `{1}`.
123
+ concat="skips-null",
124
+ concat_ws="skips-null",
125
+ set_operations_all=True,
126
+ ),
127
+ "mysql": DialectSemantics(
128
+ # MySQL under its default strict sql_mode only (docs/dialects.md), so
129
+ # a comparison of non-null operands is non-null. Not `checked_arithmetic`:
130
+ # `-x` of the least BIGINT is an exact DECIMAL rather than an error, and
131
+ # division by zero is NULL in any mode.
132
+ # Not `text_is_binary`: the default collations ignore case (and
133
+ # accents, and some trailing spaces).
134
+ zoned_timestamp_is_instant=True, # TIMESTAMP is converted to UTC.
135
+ pipes="ambiguous",
136
+ concat="strict",
137
+ concat_ws="skips-null",
138
+ inline_references=False,
139
+ foreign_key_engines=frozenset({"innodb", "ndb", "ndbcluster"}),
140
+ enum_strips_trailing_spaces=True,
141
+ set_operations_all=True, # Since 8.0.31.
142
+ ),
143
+ "snowflake": DialectSemantics(
144
+ # Not `checked_arithmetic`: its docs never say integer overflow raises.
145
+ checked_division=True,
146
+ pipes="strict",
147
+ concat="strict",
148
+ concat_ws="strict", # `concat_ws(',', 'a', NULL)` is NULL.
149
+ unique_keys="rely",
150
+ foreign_keys="rely",
151
+ ),
152
+ "bigquery": DialectSemantics(
153
+ checked_arithmetic=True,
154
+ checked_division=True,
155
+ zoned_timestamp_is_instant=True,
156
+ pipes="strict",
157
+ concat="strict",
158
+ unique_keys="unenforced",
159
+ foreign_keys="unenforced",
160
+ ),
161
+ "redshift": DialectSemantics(
162
+ checked_arithmetic=True,
163
+ checked_division=True,
164
+ zoned_timestamp_is_instant=True,
165
+ pipes="strict",
166
+ unique_keys="unenforced",
167
+ foreign_keys="unenforced",
168
+ ),
169
+ "databricks": _SPARK,
170
+ "spark": _SPARK,
171
+ "spark2": _SPARK,
172
+ "hive": DialectSemantics(casts_raise=False, pipes="strict", concat="strict", unique_keys="unenforced", foreign_keys="unenforced"),
173
+ "sqlite": DialectSemantics(
174
+ pipes="strict",
175
+ concat_ws="skips-null",
176
+ # Foreign keys are checked only under `PRAGMA foreign_keys = ON`, off by default.
177
+ foreign_keys="unenforced",
178
+ # Only an INTEGER PRIMARY KEY (the rowid) is never NULL.
179
+ primary_key_not_null=False,
180
+ ),
181
+ # A ClickHouse primary key orders and indexes a table; it never rejects a duplicate.
182
+ "clickhouse": DialectSemantics(concat_ws="strict", unique_keys="unenforced", foreign_keys="unenforced"),
183
+ "oracle": DialectSemantics(empty_string_is_null=True),
184
+ # Trino has no constraint but NOT NULL: its CREATE TABLE rejects a key, so
185
+ # one in the SQL describes a table some other engine holds.
186
+ "trino": DialectSemantics(
187
+ checked_arithmetic=True,
188
+ checked_division=True,
189
+ # VARCHAR compares by code point and Trino has no collations.
190
+ text_is_binary=True,
191
+ # Not `zoned_timestamp_is_instant`: a TIMESTAMP WITH TIME ZONE keeps
192
+ # its zone, and `CAST(ts AS VARCHAR)` writes it.
193
+ # Unlike Postgres, a NULL array or element operand makes `||` NULL too.
194
+ pipes="strict",
195
+ concat="strict",
196
+ # Not modelled: it skips a NULL VARCHAR, but its ARRAY(VARCHAR)
197
+ # overload is NULL for a NULL array, and SQLGlot parses both alike.
198
+ unique_keys="unenforced",
199
+ foreign_keys="unenforced",
200
+ set_operations_all=True,
201
+ ),
202
+ }
203
+
204
+
205
+ # The dialects whose semantics are argued here (docs/dialects.md).
206
+ MODELLED_DIALECTS = frozenset(_SEMANTICS)
207
+
208
+
209
+ def semantics(dialect: str) -> DialectSemantics:
210
+ """The semantics of a SQLGlot dialect name; cautious defaults for one not listed."""
211
+ return _SEMANTICS.get(dialect.lower(), DialectSemantics())
@@ -0,0 +1,30 @@
1
+ """Database Knowledge discovery: catalog metadata bound to IR nodes before the engine runs.
2
+
3
+ Database adapters live in submodules, such as `sqlassert.discovery.duckdb`
4
+ and `sqlassert.discovery.postgres`, so importing this package never imports
5
+ a database driver. `information_schema.py` is the base of every DB-API one.
6
+ """
7
+
8
+ from sqlassert.discovery.fetcher import (
9
+ CatalogFacts,
10
+ DatabaseColumn,
11
+ DatabaseForeignKey,
12
+ DatabaseHydration,
13
+ DatabaseKnowledgeFetcher,
14
+ DatabaseMetadataError,
15
+ DatabaseRelationKind,
16
+ DatabaseRelationMetadata,
17
+ DatabaseViewDefinition,
18
+ )
19
+
20
+ __all__ = [
21
+ "CatalogFacts",
22
+ "DatabaseColumn",
23
+ "DatabaseForeignKey",
24
+ "DatabaseHydration",
25
+ "DatabaseKnowledgeFetcher",
26
+ "DatabaseMetadataError",
27
+ "DatabaseRelationKind",
28
+ "DatabaseRelationMetadata",
29
+ "DatabaseViewDefinition",
30
+ ]
@@ -0,0 +1,71 @@
1
+ """BigQuery: the standard discovery, one dataset's `INFORMATION_SCHEMA` at a time.
2
+
3
+ Over `google.cloud.bigquery.dbapi` (`%s` parameters). BigQuery has no
4
+ current dataset a query can ask for, so the fetcher is given the project and
5
+ the default dataset unqualified names resolve in. Every view is read from
6
+ `project.dataset.INFORMATION_SCHEMA`; a name may be qualified by any project.
7
+
8
+ BigQuery has no `REFERENTIAL_CONSTRAINTS`: a foreign key's referenced table
9
+ and columns come from `CONSTRAINT_COLUMN_USAGE`, which does not order them,
10
+ so a foreign key over more than one column is skipped. Keys are not
11
+ enforced: they hold only under `trust_unenforced_keys` (`dialects.py`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ from sqlassert.discovery.fetcher import DatabaseForeignKey, DatabaseRelationMetadata
20
+ from sqlassert.discovery.information_schema import (
21
+ Connection,
22
+ InformationSchemaKnowledgeFetcher,
23
+ foreign_keys_from_pairs,
24
+ placeholders,
25
+ quoted,
26
+ )
27
+
28
+
29
+ class BigQueryKnowledgeFetcher(InformationSchemaKnowledgeFetcher):
30
+ type_dialect = "bigquery"
31
+ cross_catalog = True
32
+
33
+ def __init__(self, connection: Connection, project: str, dataset: str | None = None):
34
+ super().__init__(connection)
35
+ self.project = project
36
+ self.dataset = dataset
37
+
38
+ def session(self, scope: DatabaseRelationMetadata | None) -> tuple[str, tuple[str, ...]]:
39
+ if scope is not None:
40
+ return scope.catalog, (scope.schema,)
41
+ return self.project, (self.dataset,) if self.dataset else ()
42
+
43
+ def information_schema(self, catalog: str, schema: str) -> str:
44
+ return f"{quoted(catalog, '`')}.{quoted(schema, '`')}.INFORMATION_SCHEMA"
45
+
46
+ def foreign_keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseForeignKey]]:
47
+ where = self.information_schema(catalog, schema)
48
+ own_sql = (
49
+ "SELECT k.TABLE_NAME, k.CONSTRAINT_NAME, k.COLUMN_NAME "
50
+ f"FROM {where}.TABLE_CONSTRAINTS c JOIN {where}.KEY_COLUMN_USAGE k "
51
+ "ON k.CONSTRAINT_NAME = c.CONSTRAINT_NAME AND k.TABLE_NAME = c.TABLE_NAME "
52
+ f"WHERE c.CONSTRAINT_TYPE = 'FOREIGN KEY' AND c.TABLE_NAME IN ({placeholders(tables)}) "
53
+ "ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION"
54
+ )
55
+ own: dict[str, tuple[str, list[str]]] = {}
56
+ for table, constraint, column in self._rows(own_sql, tuple(tables)):
57
+ own.setdefault(constraint, (table, []))[1].append(column)
58
+ single = sorted(constraint for constraint, (_, members) in own.items() if len(members) == 1)
59
+ if not single:
60
+ return []
61
+ referenced_sql = (
62
+ "SELECT CONSTRAINT_NAME, TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME "
63
+ f"FROM {where}.CONSTRAINT_COLUMN_USAGE WHERE CONSTRAINT_NAME IN ({placeholders(single)})"
64
+ )
65
+ referenced: dict[str, list[tuple[Any, ...]]] = {}
66
+ for constraint, *target in self._rows(referenced_sql, tuple(single)):
67
+ referenced.setdefault(constraint, []).append(tuple(target))
68
+ return foreign_keys_from_pairs([
69
+ (own[constraint][0], constraint, own[constraint][1][0], *referenced[constraint][0])
70
+ for constraint in single if len(referenced.get(constraint, ())) == 1
71
+ ])
@@ -0,0 +1,18 @@
1
+ """Databricks (Unity Catalog): the standard discovery, over `databricks-sql-connector`.
2
+
3
+ Unity Catalog's `information_schema` has every standard view this reads, so
4
+ only the session query and the parameter style (`?`) differ. Primary and
5
+ foreign keys are informational: they hold only under
6
+ `trust_unenforced_keys` (`dialects.py`); the `RELY` a constraint may carry is
7
+ not read. Names qualified by another catalog are not looked up.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from sqlassert.discovery.information_schema import InformationSchemaKnowledgeFetcher
13
+
14
+
15
+ class DatabricksKnowledgeFetcher(InformationSchemaKnowledgeFetcher):
16
+ type_dialect = "databricks"
17
+ paramstyle = "qmark"
18
+ session_sql = "SELECT current_catalog(), current_schema()"
@@ -0,0 +1,163 @@
1
+ """DuckDB: the standard discovery, with DuckDB's `duckdb_*()` catalog functions.
2
+
3
+ Every hook reads `duckdb_tables()`, `duckdb_views()`, `duckdb_columns()` or
4
+ `duckdb_constraints()`; it never scans a table. Any DuckDB connection works
5
+ (the `duckdb` module's, or one from any other client exposing `execute`), and
6
+ sqlassert does not import a DuckDB driver.
7
+
8
+ Queries run on the connection itself, not on a cursor: a DuckDB cursor is a
9
+ new session, without the connection's temporary tables, `USE` or
10
+ `search_path`, which decide what an unqualified name denotes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+ from typing import Any, Protocol
17
+
18
+ import sqlglot
19
+ from sqlglot import exp
20
+ from sqlglot.errors import SqlglotError
21
+
22
+ from sqlassert.discovery.fetcher import (
23
+ DatabaseColumn,
24
+ DatabaseForeignKey,
25
+ DatabaseMetadataError,
26
+ DatabaseRelationKind,
27
+ DatabaseRelationMetadata,
28
+ )
29
+ from sqlassert.discovery.information_schema import FoundRelation, InformationSchemaKnowledgeFetcher, Key
30
+
31
+ _SESSION = "SELECT current_database(), current_schema(), current_setting('search_path')"
32
+
33
+ _RELATIONS = """
34
+ SELECT database_name, schema_name, table_name, 'table' FROM duckdb_tables()
35
+ WHERE lower(database_name) = %s AND lower(schema_name) = %s AND list_contains(%s, lower(table_name))
36
+ UNION ALL
37
+ SELECT database_name, schema_name, view_name, 'view' FROM duckdb_views()
38
+ WHERE NOT internal AND lower(database_name) = %s AND lower(schema_name) = %s AND list_contains(%s, lower(view_name))
39
+ """
40
+
41
+ _TABLES = "database_name = %s AND schema_name = %s AND list_contains(%s, table_name)"
42
+
43
+ _COLUMNS = f"""
44
+ SELECT table_name, column_name, is_nullable, data_type FROM duckdb_columns()
45
+ WHERE {_TABLES}
46
+ ORDER BY table_name, column_index
47
+ """
48
+
49
+ # duckdb_columns() drops collations; the stored CREATE TABLE keeps them.
50
+ _TABLE_SQL = f"SELECT table_name, sql FROM duckdb_tables() WHERE {_TABLES}"
51
+
52
+ # A foreign key's referenced table is in the referencing table's own catalog
53
+ # and schema: DuckDB rejects foreign keys across them.
54
+ _CONSTRAINTS = f"""
55
+ SELECT table_name, constraint_type, constraint_column_names, referenced_table, referenced_column_names
56
+ FROM duckdb_constraints()
57
+ WHERE constraint_type IN ({{kinds}}) AND {_TABLES}
58
+ ORDER BY table_name, constraint_index
59
+ """
60
+
61
+ _VIEW = "SELECT sql FROM duckdb_views() WHERE database_name = %s AND schema_name = %s AND view_name = %s"
62
+
63
+
64
+ class DuckDBConnection(Protocol):
65
+ def execute(self, query: str, parameters: Any = ..., /) -> Any: ...
66
+
67
+
68
+ class DuckDBKnowledgeFetcher(InformationSchemaKnowledgeFetcher):
69
+ type_dialect = "duckdb"
70
+ cross_catalog = True
71
+ _search_path: tuple[tuple[str, str], ...] = ()
72
+
73
+ def __init__(self, connection: DuckDBConnection):
74
+ self.database = connection
75
+
76
+ def session(self, scope: DatabaseRelationMetadata | None) -> tuple[str, tuple[str, ...]]:
77
+ """Where an unqualified name is looked up: a view's own schema first,
78
+ then temporary objects, then the session's path. DuckDB's path spans
79
+ catalogs, so `candidates` reads it from `_search_path`."""
80
+ (database, schema, configured), = self._rows(_SESSION)
81
+ path: list[tuple[str, str]] = [(scope.catalog, scope.schema)] if scope is not None else []
82
+ path.append(("temp", "main"))
83
+ for entry in filter(None, (entry.strip() for entry in configured.split(","))):
84
+ qualifier, _, name = entry.rpartition(".")
85
+ path.append((qualifier or database, name))
86
+ path.append((database, schema))
87
+ self._search_path = tuple((catalog.lower(), schema.lower()) for catalog, schema in path)
88
+ return database, tuple(schema for _, schema in self._search_path)
89
+
90
+ def candidates(self, name: str, catalog: str, path: tuple[str, ...]) -> list[tuple[str, str, str]]:
91
+ """Every catalog path ``name`` may denote, in DuckDB's binding order."""
92
+ match name.lower().split("."):
93
+ case [table]:
94
+ return [(searched, schema, table) for searched, schema in self._search_path]
95
+ case [qualifier, table]:
96
+ # A schema in a searched catalog first, then a catalog's default schema.
97
+ catalogs = dict.fromkeys(searched for searched, _ in self._search_path)
98
+ return [(searched, qualifier, table) for searched in catalogs] + [(qualifier, "main", table)]
99
+ case [named, schema, table]:
100
+ return [(named, schema, table)]
101
+ case _:
102
+ return []
103
+
104
+ def relations(self, catalog: str, schema: str, names: Sequence[str]) -> list[FoundRelation]:
105
+ lookup = (catalog.lower(), schema.lower(), list(names))
106
+ return [
107
+ FoundRelation(database, found_schema, name, DatabaseRelationKind(kind))
108
+ for database, found_schema, name, kind in self._rows(_RELATIONS, lookup * 2)
109
+ ]
110
+
111
+ def columns(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseColumn]]:
112
+ lookup = (catalog, schema, list(tables))
113
+ collations = self._collations(catalog, schema, lookup)
114
+ return [
115
+ (table, DatabaseColumn(column, nullable, data_type, collations.get(table, {}).get(column.lower())))
116
+ for table, column, nullable, data_type in self._rows(_COLUMNS, lookup)
117
+ ]
118
+
119
+ def keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[Key]:
120
+ return [
121
+ Key(table, tuple(members), kind == "PRIMARY KEY")
122
+ for table, kind, members, *_ in self._rows(_CONSTRAINTS.format(kinds="'PRIMARY KEY', 'UNIQUE'"), (catalog, schema, list(tables)))
123
+ ]
124
+
125
+ def foreign_keys(self, catalog: str, schema: str, tables: Sequence[str]) -> list[tuple[str, DatabaseForeignKey]]:
126
+ return [
127
+ (table, DatabaseForeignKey(tuple(members), f"{catalog}.{schema}.{referenced}", tuple(referenced_members)))
128
+ for table, _, members, referenced, referenced_members
129
+ in self._rows(_CONSTRAINTS.format(kinds="'FOREIGN KEY'"), (catalog, schema, list(tables)))
130
+ ]
131
+
132
+ def view_definition(self, relation: DatabaseRelationMetadata) -> str | None:
133
+ rows = self._rows(_VIEW, (relation.catalog, relation.schema, relation.name))
134
+ return rows[0][0] if rows else None
135
+
136
+ def _collations(self, catalog: str, schema: str, lookup: tuple[Any, ...]) -> dict[str, dict[str, str]]:
137
+ """Each table's columns declared with a COLLATE, by name. The
138
+ session's `default_collation` is not read: text without a COLLATE has
139
+ the engine's shipped (binary) default (docs/dialects.md)."""
140
+ collations: dict[str, dict[str, str]] = {}
141
+ for table, sql in self._rows(_TABLE_SQL, lookup):
142
+ try:
143
+ create = sqlglot.parse_one(sql, read="duckdb")
144
+ except SqlglotError as error:
145
+ raise DatabaseMetadataError(f"definition of table {catalog}.{schema}.{table} could not be parsed: {error}") from error
146
+ own: dict[str, str] = {}
147
+ for definition in create.find_all(exp.ColumnDef):
148
+ collate = next((
149
+ constraint.args["kind"] for constraint in definition.constraints
150
+ if isinstance(constraint.args.get("kind"), exp.CollateColumnConstraint)
151
+ ), None)
152
+ if collate is not None:
153
+ own[definition.name.lower()] = collate.this.name
154
+ collations[table] = own
155
+ return collations
156
+
157
+ def _rows(self, sql: str, parameters: tuple[Any, ...] = ()) -> list[tuple[Any, ...]]:
158
+ """The rows of one catalog query, run on the connection (see the module
159
+ docstring). Any driver error is a DatabaseMetadataError."""
160
+ try:
161
+ return [tuple(row) for row in self.database.execute(sql.replace("%s", "?"), list(parameters)).fetchall()]
162
+ except Exception as error:
163
+ raise DatabaseMetadataError(str(error)) from error