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