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