code-atlas-forge 1.1.0__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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
"""Extração AST → entidades/relações de domínio SQL (lf-0111, Phase 3).
|
|
2
|
+
|
|
3
|
+
Semântica das relações emitidas:
|
|
4
|
+
|
|
5
|
+
- ``transformation`` (1 por statement DML/SELECT com fontes)
|
|
6
|
+
``READS_FROM`` cada tabela terminal lida (CTEs/subqueries são
|
|
7
|
+
transparentes — a leitura liga à tabela real);
|
|
8
|
+
- ``transformation`` ``WRITES_TO`` alvo de INSERT/CTAS/VIEW;
|
|
9
|
+
- ``transformation`` ``TRANSFORMS`` alvo de UPDATE/DELETE/MERGE/TRUNCATE
|
|
10
|
+
(mutação in-place, não carga);
|
|
11
|
+
- ``target`` ``DERIVES_FROM`` fonte — linhagem tabela→tabela colapsada;
|
|
12
|
+
- ``coluna_out`` ``DERIVES_FROM`` ``coluna_src`` — linhagem de coluna
|
|
13
|
+
**somente quando a expressão resolve honestamente** (alias/tabela
|
|
14
|
+
únicos, sem ``SELECT *``, sem subquery de saída desconhecida).
|
|
15
|
+
|
|
16
|
+
Regras de honestidade (hard):
|
|
17
|
+
|
|
18
|
+
- ``SELECT *``/``t.*`` em statement produtor → diagnóstico
|
|
19
|
+
``select-star`` (linhagem de coluna limitada; a de tabela mantém-se);
|
|
20
|
+
- colref não-qualificado com ≥2 fontes potenciais → ``ambiguous-column``,
|
|
21
|
+
nunca promovido; qualificador que não casa nenhuma fonte →
|
|
22
|
+
``unresolved-column``;
|
|
23
|
+
- ``${}``/``{{}}``/``EXEC``/fonte dinâmica → ``dynamic-sql`` + relação
|
|
24
|
+
``to_unresolved`` com o texto preservado — nada fabricado;
|
|
25
|
+
- fonte não-tabela (UNNEST, LATERAL VIEW, função-de-tabela) →
|
|
26
|
+
``unsupported-source`` info — o statement continua extraído;
|
|
27
|
+
- qualquer falha interna por statement → ``extract-error``, o resto do
|
|
28
|
+
arquivo continua (parcial honesto, nunca crash).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from code_atlas.domains.contracts import (
|
|
38
|
+
DomainDiagnostic,
|
|
39
|
+
ExtractedDomainEntity,
|
|
40
|
+
ExtractedDomainRelation,
|
|
41
|
+
)
|
|
42
|
+
from code_atlas.domains.evidence import evidence_record
|
|
43
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
44
|
+
from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
|
|
45
|
+
from code_atlas.domains.sql.nodes import (
|
|
46
|
+
ColRef,
|
|
47
|
+
Cte,
|
|
48
|
+
ExprRefs,
|
|
49
|
+
NameRef,
|
|
50
|
+
SCreateTable,
|
|
51
|
+
SCreateView,
|
|
52
|
+
SDelete,
|
|
53
|
+
SelectStmt,
|
|
54
|
+
SInsert,
|
|
55
|
+
SMerge,
|
|
56
|
+
Source,
|
|
57
|
+
SrcGroup,
|
|
58
|
+
SrcJoin,
|
|
59
|
+
SrcSub,
|
|
60
|
+
SrcTable,
|
|
61
|
+
SrcUnsupported,
|
|
62
|
+
SrcValues,
|
|
63
|
+
SSelect,
|
|
64
|
+
Statement,
|
|
65
|
+
STruncate,
|
|
66
|
+
SUnsupported,
|
|
67
|
+
SUpdate,
|
|
68
|
+
SUse,
|
|
69
|
+
)
|
|
70
|
+
from code_atlas.domains.sql.parser import ParsedFile
|
|
71
|
+
from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
|
|
72
|
+
|
|
73
|
+
SQL_DOMAIN = "sql"
|
|
74
|
+
_DIAG_CAP = 60 # diagnósticos por artefato — depois vira resumo
|
|
75
|
+
_SCOPE_DEPTH = 16 # recursão máxima CTE/subquery — patológico além disso
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class _EntAcc:
|
|
80
|
+
identity: DomainIdentity
|
|
81
|
+
kind: DomainEntityKind
|
|
82
|
+
name: str
|
|
83
|
+
span: SourceSpan | None
|
|
84
|
+
attrs: dict[str, str]
|
|
85
|
+
prio: int
|
|
86
|
+
ev: set[int] = field(default_factory=set)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class _RelAcc:
|
|
91
|
+
rtype: str
|
|
92
|
+
from_i: DomainIdentity
|
|
93
|
+
to_i: DomainIdentity | None
|
|
94
|
+
to_text: str
|
|
95
|
+
span: SourceSpan | None
|
|
96
|
+
ev: set[int] = field(default_factory=set)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- escopo de resolução de colunas --------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class _Out:
|
|
104
|
+
"""Coluna de saída nomeada de uma subquery/CTE."""
|
|
105
|
+
|
|
106
|
+
name: str | None
|
|
107
|
+
refs: ExprRefs
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class _TblSrc:
|
|
112
|
+
ident: DomainIdentity
|
|
113
|
+
parts: tuple[str, ...]
|
|
114
|
+
alias: str | None
|
|
115
|
+
span: SourceSpan
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class _SubSrc:
|
|
120
|
+
alias: str | None
|
|
121
|
+
outputs: list[_Out]
|
|
122
|
+
inner: _Scope
|
|
123
|
+
star: bool # saída parcialmente desconhecida (SELECT *)
|
|
124
|
+
literal: bool = False # VALUES — nomes conhecidos, sem upstream
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class _DeadSrc:
|
|
129
|
+
"""Fonte não-resolvível (UNNEST, função, dinâmica) — nunca casa."""
|
|
130
|
+
|
|
131
|
+
alias: str | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_SrcInfo = _TblSrc | _SubSrc | _DeadSrc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class _Scope:
|
|
139
|
+
sources: list[_SrcInfo]
|
|
140
|
+
parent: _Scope | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class _RCol:
|
|
145
|
+
terminals: list[tuple[DomainIdentity, str]] = field(default_factory=list)
|
|
146
|
+
problems: list[tuple[str, str]] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _FileCtx:
|
|
150
|
+
"""Acumulador por artefato: entidades/relações/evidência/diagnósticos."""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self, *, repository: str, root: str, environment: str,
|
|
154
|
+
path: str, sha: str, dialect: str, adapter_version: str,
|
|
155
|
+
) -> None:
|
|
156
|
+
self.repo = repository
|
|
157
|
+
self.root = root
|
|
158
|
+
self.environment = environment
|
|
159
|
+
self.path = path
|
|
160
|
+
self.sha = sha
|
|
161
|
+
self.dialect = dialect
|
|
162
|
+
self.version = adapter_version
|
|
163
|
+
self.entities: dict[str, _EntAcc] = {}
|
|
164
|
+
self.relations: dict[tuple[str, str, str], _RelAcc] = {}
|
|
165
|
+
self.evidence: list[EvidenceRecord] = []
|
|
166
|
+
self._evidx: dict[int, int] = {}
|
|
167
|
+
self.diagnostics: list[DomainDiagnostic] = []
|
|
168
|
+
self.stats: dict[str, int] = {}
|
|
169
|
+
# contexto USE — sequencial dentro do arquivo
|
|
170
|
+
self.use_catalog = ""
|
|
171
|
+
self.use_schema = ""
|
|
172
|
+
self._truncated_diags = 0
|
|
173
|
+
|
|
174
|
+
# --- evidência / diagnóstico ---------------------------------------
|
|
175
|
+
|
|
176
|
+
def evi(self, stmt_i: int, span: SourceSpan | None) -> int:
|
|
177
|
+
if stmt_i in self._evidx:
|
|
178
|
+
return self._evidx[stmt_i]
|
|
179
|
+
self.evidence.append(
|
|
180
|
+
evidence_record(
|
|
181
|
+
adapter_domain=SQL_DOMAIN,
|
|
182
|
+
adapter_version=self.version,
|
|
183
|
+
path=self.path,
|
|
184
|
+
content_hash=self.sha,
|
|
185
|
+
span=span,
|
|
186
|
+
notes=f"stmt:{stmt_i}",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
idx = len(self.evidence) - 1
|
|
190
|
+
self._evidx[stmt_i] = idx
|
|
191
|
+
return idx
|
|
192
|
+
|
|
193
|
+
def diag(self, code: str, severity: str, message: str) -> None:
|
|
194
|
+
if len(self.diagnostics) >= _DIAG_CAP:
|
|
195
|
+
self._truncated_diags += 1
|
|
196
|
+
return
|
|
197
|
+
self.diagnostics.append(
|
|
198
|
+
DomainDiagnostic(
|
|
199
|
+
code=code, severity=severity,
|
|
200
|
+
message=message, path=self.path,
|
|
201
|
+
adapter_id=SQL_DOMAIN,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def finish_diags(self) -> None:
|
|
206
|
+
if self._truncated_diags:
|
|
207
|
+
self.diagnostics.append(
|
|
208
|
+
DomainDiagnostic(
|
|
209
|
+
code="diagnostics-truncated", severity="info",
|
|
210
|
+
message=(
|
|
211
|
+
f"{self._truncated_diags} further diagnostics "
|
|
212
|
+
f"suppressed (cap {_DIAG_CAP})"
|
|
213
|
+
),
|
|
214
|
+
path=self.path, adapter_id=SQL_DOMAIN,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# --- identidades ------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def table_ident(self, parts: tuple[str, ...]) -> DomainIdentity:
|
|
221
|
+
"""catalog/namespace/qualified_name a partir das partes do nome."""
|
|
222
|
+
if len(parts) >= 3:
|
|
223
|
+
catalog, namespace, name = (
|
|
224
|
+
parts[0], ".".join(parts[1:-1]), parts[-1]
|
|
225
|
+
)
|
|
226
|
+
elif len(parts) == 2:
|
|
227
|
+
catalog, namespace, name = self.use_catalog, parts[0], parts[1]
|
|
228
|
+
else:
|
|
229
|
+
catalog, namespace, name = (
|
|
230
|
+
self.use_catalog, self.use_schema, parts[0]
|
|
231
|
+
)
|
|
232
|
+
return DomainIdentity(
|
|
233
|
+
domain=SQL_DOMAIN, repository=self.repo, root=self.root,
|
|
234
|
+
catalog=catalog, namespace=namespace, environment=self.environment,
|
|
235
|
+
qualified_name=name,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def col_ident(self, table: DomainIdentity, col: str) -> DomainIdentity:
|
|
239
|
+
return DomainIdentity(
|
|
240
|
+
domain=SQL_DOMAIN, repository=self.repo, root=self.root,
|
|
241
|
+
catalog=table.catalog, namespace=table.namespace,
|
|
242
|
+
environment=table.environment,
|
|
243
|
+
qualified_name=f"{table.qualified_name}.{col}",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# --- entidades / relações --------------------------------------------
|
|
247
|
+
|
|
248
|
+
def add_entity(
|
|
249
|
+
self,
|
|
250
|
+
ident: DomainIdentity,
|
|
251
|
+
kind: DomainEntityKind,
|
|
252
|
+
name: str,
|
|
253
|
+
span: SourceSpan | None,
|
|
254
|
+
attrs: dict[str, str],
|
|
255
|
+
prio: int,
|
|
256
|
+
evi: int,
|
|
257
|
+
) -> None:
|
|
258
|
+
key = ident.logical_key()
|
|
259
|
+
acc = self.entities.get(key)
|
|
260
|
+
if acc is None:
|
|
261
|
+
self.entities[key] = _EntAcc(
|
|
262
|
+
identity=ident, kind=kind, name=name, span=span,
|
|
263
|
+
attrs=dict(attrs), prio=prio, ev={evi},
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
acc.ev.add(evi)
|
|
267
|
+
if prio > acc.prio:
|
|
268
|
+
acc.kind = kind
|
|
269
|
+
acc.name = name
|
|
270
|
+
acc.span = span
|
|
271
|
+
acc.attrs = dict(attrs)
|
|
272
|
+
acc.prio = prio
|
|
273
|
+
elif prio == acc.prio and acc.kind is not kind:
|
|
274
|
+
self.diag(
|
|
275
|
+
"conflicting-declarations", "warning",
|
|
276
|
+
f"{ident.display()} declared as both "
|
|
277
|
+
f"{acc.kind.value} and {kind.value}",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def add_rel(
|
|
281
|
+
self,
|
|
282
|
+
rtype: str,
|
|
283
|
+
from_i: DomainIdentity,
|
|
284
|
+
*,
|
|
285
|
+
to_i: DomainIdentity | None = None,
|
|
286
|
+
to_text: str = "",
|
|
287
|
+
span: SourceSpan | None = None,
|
|
288
|
+
evi: int,
|
|
289
|
+
) -> None:
|
|
290
|
+
to_key = to_i.logical_key() if to_i is not None else f"text:{to_text}"
|
|
291
|
+
key = (rtype, from_i.logical_key(), to_key)
|
|
292
|
+
acc = self.relations.get(key)
|
|
293
|
+
if acc is None:
|
|
294
|
+
self.relations[key] = _RelAcc(
|
|
295
|
+
rtype=rtype, from_i=from_i, to_i=to_i,
|
|
296
|
+
to_text=to_text, span=span, ev={evi},
|
|
297
|
+
)
|
|
298
|
+
return
|
|
299
|
+
acc.ev.add(evi)
|
|
300
|
+
|
|
301
|
+
def table_entity(
|
|
302
|
+
self, name: NameRef, evi: int, *, declared: bool, kind: DomainEntityKind
|
|
303
|
+
) -> DomainIdentity:
|
|
304
|
+
ident = self.table_ident(name.parts)
|
|
305
|
+
attrs = {
|
|
306
|
+
"written_name": name.written,
|
|
307
|
+
"dialect": self.dialect,
|
|
308
|
+
"declared": "create" if declared else "reference",
|
|
309
|
+
}
|
|
310
|
+
self.add_entity(
|
|
311
|
+
ident, kind, name.parts[-1], name.span, attrs,
|
|
312
|
+
prio=3 if declared else 1, evi=evi,
|
|
313
|
+
)
|
|
314
|
+
return ident
|
|
315
|
+
|
|
316
|
+
def col_entity(
|
|
317
|
+
self, table: DomainIdentity, col: str, evi: int, *, declared: bool
|
|
318
|
+
) -> DomainIdentity:
|
|
319
|
+
ident = self.col_ident(table, col)
|
|
320
|
+
self.add_entity(
|
|
321
|
+
ident, DomainEntityKind.DATA_COLUMN, col, None,
|
|
322
|
+
{
|
|
323
|
+
"table": table.display(),
|
|
324
|
+
"dialect": self.dialect,
|
|
325
|
+
"declared": "create" if declared else "reference",
|
|
326
|
+
},
|
|
327
|
+
prio=3 if declared else 1, evi=evi,
|
|
328
|
+
)
|
|
329
|
+
return ident
|
|
330
|
+
|
|
331
|
+
def transform_entity(
|
|
332
|
+
self, stmt_i: int, st: Statement, evi: int
|
|
333
|
+
) -> DomainIdentity:
|
|
334
|
+
qname = f"{self.path}#{stmt_i}"
|
|
335
|
+
ident = DomainIdentity(
|
|
336
|
+
domain=SQL_DOMAIN, repository=self.repo, root=self.root,
|
|
337
|
+
environment=self.environment, qualified_name=qname,
|
|
338
|
+
)
|
|
339
|
+
self.add_entity(
|
|
340
|
+
ident, DomainEntityKind.DATA_TRANSFORMATION,
|
|
341
|
+
f"{st.kind}#{stmt_i}", st.span,
|
|
342
|
+
{
|
|
343
|
+
"artifact": self.path,
|
|
344
|
+
"statement": st.kind,
|
|
345
|
+
"dialect": self.dialect,
|
|
346
|
+
},
|
|
347
|
+
prio=1, evi=evi,
|
|
348
|
+
)
|
|
349
|
+
return ident
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# --- coleta de tabelas terminais ------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _cte_env(ctes: list[Cte], base: dict[str, Cte] | None = None) -> dict[str, Cte]:
|
|
356
|
+
env = dict(base or {})
|
|
357
|
+
for c in ctes:
|
|
358
|
+
env[c.name] = c
|
|
359
|
+
return env
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _srcs_tables(
|
|
363
|
+
srcs: list[Source],
|
|
364
|
+
env: dict[str, Cte],
|
|
365
|
+
out: list[SrcTable],
|
|
366
|
+
dyn: list[SrcUnsupported],
|
|
367
|
+
ctx: _FileCtx,
|
|
368
|
+
depth: int,
|
|
369
|
+
shadow: frozenset[str] = frozenset(),
|
|
370
|
+
) -> None:
|
|
371
|
+
for s in srcs:
|
|
372
|
+
if isinstance(s, SrcJoin):
|
|
373
|
+
_srcs_tables([s.left, s.right], env, out, dyn, ctx, depth, shadow)
|
|
374
|
+
elif isinstance(s, SrcGroup):
|
|
375
|
+
_srcs_tables([s.inner], env, out, dyn, ctx, depth, shadow)
|
|
376
|
+
elif isinstance(s, SrcTable):
|
|
377
|
+
_table_read(s, env, out, dyn, ctx, depth, shadow)
|
|
378
|
+
elif isinstance(s, SrcSub):
|
|
379
|
+
if depth < _SCOPE_DEPTH:
|
|
380
|
+
env2 = _cte_env(s.query.ctes, env)
|
|
381
|
+
_query_tables(
|
|
382
|
+
s.query, env2, out, dyn, ctx, depth + 1, shadow
|
|
383
|
+
)
|
|
384
|
+
elif isinstance(s, SrcUnsupported):
|
|
385
|
+
if s.what == "dynamic":
|
|
386
|
+
dyn.append(s)
|
|
387
|
+
else:
|
|
388
|
+
ctx.diag(
|
|
389
|
+
"unsupported-source", "info",
|
|
390
|
+
f"table source not expanded: {s.written} ({s.what})",
|
|
391
|
+
)
|
|
392
|
+
# SrcValues: dados literais — sem upstream
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _table_read(
|
|
396
|
+
s: SrcTable,
|
|
397
|
+
env: dict[str, Cte],
|
|
398
|
+
out: list[SrcTable],
|
|
399
|
+
dyn: list[SrcUnsupported],
|
|
400
|
+
ctx: _FileCtx,
|
|
401
|
+
depth: int,
|
|
402
|
+
shadow: frozenset[str],
|
|
403
|
+
) -> None:
|
|
404
|
+
"""SrcTable: expande CTE, diagnostica auto-recursão, ou tabela base."""
|
|
405
|
+
bare = len(s.name.parts) == 1
|
|
406
|
+
if bare and s.name.parts[0] in env:
|
|
407
|
+
cte = env[s.name.parts[0]]
|
|
408
|
+
if depth < _SCOPE_DEPTH:
|
|
409
|
+
env2 = _cte_env(cte.query.ctes, env)
|
|
410
|
+
env2.pop(cte.name, None) # corta auto-recursão
|
|
411
|
+
_query_tables(
|
|
412
|
+
cte.query, env2, out, dyn, ctx, depth + 1,
|
|
413
|
+
shadow | {cte.name},
|
|
414
|
+
)
|
|
415
|
+
return
|
|
416
|
+
if bare and s.name.parts[0] in shadow:
|
|
417
|
+
# auto-referência de CTE recursiva — nunca vira tabela base
|
|
418
|
+
ctx.diag(
|
|
419
|
+
"recursive-cte", "info",
|
|
420
|
+
f"recursive CTE self-reference not expanded: "
|
|
421
|
+
f"{s.name.written}",
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
out.append(s)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _query_tables(
|
|
428
|
+
q: SelectStmt,
|
|
429
|
+
env: dict[str, Cte],
|
|
430
|
+
out: list[SrcTable],
|
|
431
|
+
dyn: list[SrcUnsupported],
|
|
432
|
+
ctx: _FileCtx,
|
|
433
|
+
depth: int,
|
|
434
|
+
shadow: frozenset[str] = frozenset(),
|
|
435
|
+
) -> None:
|
|
436
|
+
_srcs_tables(q.sources, env, out, dyn, ctx, depth, shadow)
|
|
437
|
+
for br in q.set_branches:
|
|
438
|
+
env2 = _cte_env(br.ctes, env)
|
|
439
|
+
_query_tables(br, env2, out, dyn, ctx, depth + 1, shadow)
|
|
440
|
+
for sq in q.tail_subqueries:
|
|
441
|
+
env2 = _cte_env(sq.ctes, env)
|
|
442
|
+
_query_tables(sq, env2, out, dyn, ctx, depth + 1, shadow)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# --- escopo de resolução de colunas ---------------------------------------
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _outputs_of(select: SelectStmt) -> tuple[list[_Out], bool]:
|
|
449
|
+
"""Saídas nomeadas do select principal; ``star`` = saída desconhecida."""
|
|
450
|
+
outs: list[_Out] = []
|
|
451
|
+
star = False
|
|
452
|
+
for item in select.items:
|
|
453
|
+
if item.star is not None:
|
|
454
|
+
star = True
|
|
455
|
+
continue
|
|
456
|
+
name = item.alias
|
|
457
|
+
if name is None and len(item.refs.cols) == 1 and not item.refs.stars:
|
|
458
|
+
name = item.refs.cols[0].parts[-1]
|
|
459
|
+
outs.append(_Out(name=name, refs=item.refs))
|
|
460
|
+
return outs, star
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _build_scope(
|
|
464
|
+
srcs: list[Source],
|
|
465
|
+
env: dict[str, Cte],
|
|
466
|
+
ctx: _FileCtx,
|
|
467
|
+
parent: _Scope | None,
|
|
468
|
+
depth: int,
|
|
469
|
+
seen: frozenset[int],
|
|
470
|
+
) -> _Scope:
|
|
471
|
+
"""Constrói o escopo de resolução de uma cláusula FROM."""
|
|
472
|
+
scope = _Scope(sources=[], parent=parent)
|
|
473
|
+
for s in srcs:
|
|
474
|
+
info = _src_info(s, env, ctx, scope, depth, seen)
|
|
475
|
+
if isinstance(info, list):
|
|
476
|
+
scope.sources.extend(info)
|
|
477
|
+
elif info is not None:
|
|
478
|
+
scope.sources.append(info)
|
|
479
|
+
return scope
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _src_info(
|
|
483
|
+
s: Source,
|
|
484
|
+
env: dict[str, Cte],
|
|
485
|
+
ctx: _FileCtx,
|
|
486
|
+
scope: _Scope,
|
|
487
|
+
depth: int,
|
|
488
|
+
seen: frozenset[int],
|
|
489
|
+
) -> _SrcInfo | list[_SrcInfo] | None:
|
|
490
|
+
if isinstance(s, SrcJoin):
|
|
491
|
+
left = _src_info(s.left, env, ctx, scope, depth, seen)
|
|
492
|
+
right = _src_info(s.right, env, ctx, scope, depth, seen)
|
|
493
|
+
out: list[_SrcInfo] = []
|
|
494
|
+
for v in (left, right):
|
|
495
|
+
if isinstance(v, list):
|
|
496
|
+
out.extend(v)
|
|
497
|
+
elif v is not None:
|
|
498
|
+
out.append(v)
|
|
499
|
+
return out
|
|
500
|
+
if isinstance(s, SrcGroup):
|
|
501
|
+
inner = _src_info(s.inner, env, ctx, scope, depth, seen)
|
|
502
|
+
inner_list = (
|
|
503
|
+
inner if isinstance(inner, list)
|
|
504
|
+
else [inner] if inner is not None else []
|
|
505
|
+
)
|
|
506
|
+
group_scope = _Scope(sources=inner_list, parent=scope)
|
|
507
|
+
return _SubSrc(
|
|
508
|
+
alias=s.alias, outputs=[], inner=group_scope, star=True,
|
|
509
|
+
)
|
|
510
|
+
if isinstance(s, SrcTable):
|
|
511
|
+
return _src_table_info(s, env, ctx, scope, depth, seen)
|
|
512
|
+
if isinstance(s, SrcSub):
|
|
513
|
+
return _src_sub_info(s, env, ctx, scope, depth, seen)
|
|
514
|
+
if isinstance(s, SrcValues):
|
|
515
|
+
outs = [_Out(name=c, refs=ExprRefs()) for c in s.col_aliases]
|
|
516
|
+
val_scope = _Scope(sources=[], parent=scope)
|
|
517
|
+
return _SubSrc(
|
|
518
|
+
alias=s.alias, outputs=outs, inner=val_scope,
|
|
519
|
+
star=not s.col_aliases, literal=True,
|
|
520
|
+
)
|
|
521
|
+
if isinstance(s, SrcUnsupported):
|
|
522
|
+
return _DeadSrc()
|
|
523
|
+
return None # type: ignore[unreachable] # subclasses futuras de Source
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _src_table_info(
|
|
527
|
+
s: SrcTable,
|
|
528
|
+
env: dict[str, Cte],
|
|
529
|
+
ctx: _FileCtx,
|
|
530
|
+
scope: _Scope,
|
|
531
|
+
depth: int,
|
|
532
|
+
seen: frozenset[int],
|
|
533
|
+
) -> _SrcInfo:
|
|
534
|
+
"""Nome em ``env`` = CTE (vira ``_SubSrc``); senão, tabela real."""
|
|
535
|
+
if len(s.name.parts) == 1 and s.name.parts[0] in env:
|
|
536
|
+
cte = env[s.name.parts[0]]
|
|
537
|
+
if depth >= _SCOPE_DEPTH or id(cte.query) in seen:
|
|
538
|
+
# auto-recursão / profundidade — fonte real, não-resolvível
|
|
539
|
+
return _DeadSrc(alias=s.alias or cte.name)
|
|
540
|
+
env2 = _cte_env(cte.query.ctes, env)
|
|
541
|
+
# o próprio nome fica em env2: auto-referência recai no
|
|
542
|
+
# ``seen`` acima → _DeadSrc, nunca _TblSrc fabricado
|
|
543
|
+
inner = _build_scope(
|
|
544
|
+
cte.query.sources, env2, ctx, scope, depth + 1,
|
|
545
|
+
seen | {id(cte.query)},
|
|
546
|
+
)
|
|
547
|
+
outs, star = _outputs_of(cte.query)
|
|
548
|
+
outputs = _apply_col_aliases(outs, cte.columns)
|
|
549
|
+
return _SubSrc(
|
|
550
|
+
alias=s.alias or cte.name, outputs=outputs,
|
|
551
|
+
inner=inner, star=star,
|
|
552
|
+
)
|
|
553
|
+
return _TblSrc(
|
|
554
|
+
ident=ctx.table_ident(s.name.parts),
|
|
555
|
+
parts=s.name.parts,
|
|
556
|
+
alias=s.alias or s.name.parts[-1],
|
|
557
|
+
span=s.name.span,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _src_sub_info(
|
|
562
|
+
s: SrcSub,
|
|
563
|
+
env: dict[str, Cte],
|
|
564
|
+
ctx: _FileCtx,
|
|
565
|
+
scope: _Scope,
|
|
566
|
+
depth: int,
|
|
567
|
+
seen: frozenset[int],
|
|
568
|
+
) -> _SrcInfo:
|
|
569
|
+
"""Subquery parentizada — escopo interno + outputs nomeados."""
|
|
570
|
+
if depth >= _SCOPE_DEPTH or id(s.query) in seen:
|
|
571
|
+
return _DeadSrc(alias=s.alias)
|
|
572
|
+
env2 = _cte_env(s.query.ctes, env)
|
|
573
|
+
inner = _build_scope(
|
|
574
|
+
s.query.sources, env2, ctx, scope, depth + 1,
|
|
575
|
+
seen | {id(s.query)},
|
|
576
|
+
)
|
|
577
|
+
outs, star = _outputs_of(s.query)
|
|
578
|
+
outputs = _apply_col_aliases(outs, s.col_aliases)
|
|
579
|
+
return _SubSrc(
|
|
580
|
+
alias=s.alias, outputs=outputs, inner=inner, star=star,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _apply_col_aliases(outs: list[_Out], aliases: tuple[str, ...]) -> list[_Out]:
|
|
585
|
+
if not aliases:
|
|
586
|
+
return outs
|
|
587
|
+
out = list(outs)
|
|
588
|
+
for i, a in enumerate(aliases):
|
|
589
|
+
if i < len(out):
|
|
590
|
+
out[i] = _Out(name=a, refs=out[i].refs)
|
|
591
|
+
else:
|
|
592
|
+
out.append(_Out(name=a, refs=ExprRefs()))
|
|
593
|
+
return out
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _may_expose(s: _SrcInfo, name: str) -> bool:
|
|
597
|
+
if isinstance(s, _TblSrc):
|
|
598
|
+
return True
|
|
599
|
+
if isinstance(s, _SubSrc):
|
|
600
|
+
if s.star:
|
|
601
|
+
return True
|
|
602
|
+
return any(o.name == name for o in s.outputs)
|
|
603
|
+
return False
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _match_qual(s: _SrcInfo, qual: tuple[str, ...]) -> bool:
|
|
607
|
+
if isinstance(s, _TblSrc):
|
|
608
|
+
if s.alias is not None:
|
|
609
|
+
return len(qual) == 1 and s.alias == qual[0]
|
|
610
|
+
return len(qual) <= len(s.parts) and s.parts[-len(qual):] == qual
|
|
611
|
+
if isinstance(s, _SubSrc):
|
|
612
|
+
return s.alias is not None and len(qual) == 1 and s.alias == qual[0]
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _into(
|
|
617
|
+
s: _SrcInfo,
|
|
618
|
+
colname: str,
|
|
619
|
+
ctx: _FileCtx,
|
|
620
|
+
depth: int,
|
|
621
|
+
seen: frozenset[int],
|
|
622
|
+
) -> _RCol:
|
|
623
|
+
"""Resolve ``s.<colname>`` dentro da fonte — terminais + problemas."""
|
|
624
|
+
res = _RCol()
|
|
625
|
+
if isinstance(s, _TblSrc):
|
|
626
|
+
res.terminals.append((s.ident, colname))
|
|
627
|
+
return res
|
|
628
|
+
if isinstance(s, _SubSrc):
|
|
629
|
+
if s.literal:
|
|
630
|
+
return res # VALUES — resolvido, sem upstream
|
|
631
|
+
matched = [o for o in s.outputs if o.name == colname]
|
|
632
|
+
if not matched:
|
|
633
|
+
if s.star:
|
|
634
|
+
res.problems.append(
|
|
635
|
+
("unresolved-column", f"{colname} in star-expanding "
|
|
636
|
+
f"source {s.alias!r} — terminal unknown")
|
|
637
|
+
)
|
|
638
|
+
else:
|
|
639
|
+
res.problems.append(
|
|
640
|
+
("unresolved-column", f"{colname} not in outputs of "
|
|
641
|
+
f"{s.alias!r}")
|
|
642
|
+
)
|
|
643
|
+
return res
|
|
644
|
+
for o in matched:
|
|
645
|
+
res = _merge_rcol(
|
|
646
|
+
res, _resolve_refs(o.refs, s.inner, ctx, depth + 1, seen)
|
|
647
|
+
)
|
|
648
|
+
return res
|
|
649
|
+
res.problems.append(
|
|
650
|
+
("unresolved-column", f"{colname} on opaque source")
|
|
651
|
+
)
|
|
652
|
+
return res
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _merge_rcol(a: _RCol, b: _RCol) -> _RCol:
|
|
656
|
+
a.terminals.extend(b.terminals)
|
|
657
|
+
a.problems.extend(b.problems)
|
|
658
|
+
return a
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _resolve_refs(
|
|
662
|
+
refs: ExprRefs,
|
|
663
|
+
scope: _Scope,
|
|
664
|
+
ctx: _FileCtx,
|
|
665
|
+
depth: int,
|
|
666
|
+
seen: frozenset[int],
|
|
667
|
+
) -> _RCol:
|
|
668
|
+
"""Terminais de coluna para todos os colrefs de uma expressão.
|
|
669
|
+
|
|
670
|
+
Subqueries na expressão expandem com escopo próprio encadeado ao
|
|
671
|
+
pai (correlação honesta).
|
|
672
|
+
"""
|
|
673
|
+
res = _RCol()
|
|
674
|
+
if depth >= _SCOPE_DEPTH:
|
|
675
|
+
res.problems.append(("unresolved-column", "resolution depth exceeded"))
|
|
676
|
+
return res
|
|
677
|
+
for col in refs.cols:
|
|
678
|
+
res = _merge_rcol(res, _resolve_col(col, scope, ctx, depth, seen))
|
|
679
|
+
for sq in refs.subqueries:
|
|
680
|
+
if id(sq) in seen:
|
|
681
|
+
continue
|
|
682
|
+
sub_env = _cte_env(sq.ctes, {})
|
|
683
|
+
inner = _build_scope(
|
|
684
|
+
sq.sources, sub_env, ctx, scope, depth + 1, seen | {id(sq)}
|
|
685
|
+
)
|
|
686
|
+
for item in sq.items:
|
|
687
|
+
res = _merge_rcol(
|
|
688
|
+
res,
|
|
689
|
+
_resolve_refs(item.refs, inner, ctx, depth + 1, seen | {id(sq)}),
|
|
690
|
+
)
|
|
691
|
+
for br in sq.set_branches:
|
|
692
|
+
for item in br.items:
|
|
693
|
+
res = _merge_rcol(
|
|
694
|
+
res,
|
|
695
|
+
_resolve_refs(
|
|
696
|
+
item.refs, inner, ctx, depth + 1, seen | {id(sq)}
|
|
697
|
+
),
|
|
698
|
+
)
|
|
699
|
+
return res
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _resolve_col(
|
|
703
|
+
col: ColRef,
|
|
704
|
+
scope: _Scope,
|
|
705
|
+
ctx: _FileCtx,
|
|
706
|
+
depth: int,
|
|
707
|
+
seen: frozenset[int],
|
|
708
|
+
) -> _RCol:
|
|
709
|
+
res = _RCol()
|
|
710
|
+
parts = col.parts
|
|
711
|
+
if len(parts) == 1:
|
|
712
|
+
name = parts[0]
|
|
713
|
+
cands = [s for s in scope.sources if _may_expose(s, name)]
|
|
714
|
+
if len(cands) == 1:
|
|
715
|
+
return _into(cands[0], name, ctx, depth, seen)
|
|
716
|
+
if len(cands) > 1:
|
|
717
|
+
res.problems.append(
|
|
718
|
+
("ambiguous-column",
|
|
719
|
+
f"unqualified {name!r} matches {len(cands)} sources")
|
|
720
|
+
)
|
|
721
|
+
return res
|
|
722
|
+
if scope.parent is not None:
|
|
723
|
+
return _resolve_col(col, scope.parent, ctx, depth + 1, seen)
|
|
724
|
+
res.problems.append(
|
|
725
|
+
("unresolved-column", f"unqualified {name!r} matches no source")
|
|
726
|
+
)
|
|
727
|
+
return res
|
|
728
|
+
# qualificado: maior prefixo primeiro
|
|
729
|
+
for k in range(len(parts) - 1, 0, -1):
|
|
730
|
+
qual = tuple(parts[:k])
|
|
731
|
+
colname = parts[k]
|
|
732
|
+
matches = [s for s in scope.sources if _match_qual(s, qual)]
|
|
733
|
+
if len(matches) > 1:
|
|
734
|
+
res.problems.append(
|
|
735
|
+
("ambiguous-column",
|
|
736
|
+
f"{'.'.join(parts)!r}: qualifier "
|
|
737
|
+
f"{'.'.join(qual)!r} matches {len(matches)} sources")
|
|
738
|
+
)
|
|
739
|
+
return res
|
|
740
|
+
if len(matches) == 1:
|
|
741
|
+
return _into(matches[0], colname, ctx, depth, seen)
|
|
742
|
+
if scope.parent is not None:
|
|
743
|
+
return _resolve_col(col, scope.parent, ctx, depth + 1, seen)
|
|
744
|
+
res.problems.append(
|
|
745
|
+
("unresolved-column",
|
|
746
|
+
f"{'.'.join(parts)!r}: qualifier matches no source")
|
|
747
|
+
)
|
|
748
|
+
return res
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# --- extração por statement -------------------------------------------------
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _star_diag(ctx: _FileCtx, st: Statement, q: SelectStmt | None) -> None:
|
|
755
|
+
if q is None:
|
|
756
|
+
return
|
|
757
|
+
for item in q.items:
|
|
758
|
+
if item.star is not None:
|
|
759
|
+
qual = item.star.qualifier
|
|
760
|
+
what = f"{'.'.join(qual)}.*" if qual else "*"
|
|
761
|
+
ctx.diag(
|
|
762
|
+
"select-star", "warning",
|
|
763
|
+
f"{st.kind} expands {what} — column lineage is "
|
|
764
|
+
"limited to non-star items; table lineage kept",
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _col_lineage(
|
|
769
|
+
ctx: _FileCtx,
|
|
770
|
+
scope: _Scope,
|
|
771
|
+
pairs: list[tuple[str, ExprRefs]],
|
|
772
|
+
target_i: DomainIdentity,
|
|
773
|
+
evi: int,
|
|
774
|
+
seen: frozenset[int] = frozenset(),
|
|
775
|
+
) -> None:
|
|
776
|
+
"""Emite DERIVES_FROM col→col para pares (alvo, expr-fonte)."""
|
|
777
|
+
for tcol, refs in pairs:
|
|
778
|
+
res = _resolve_refs(refs, scope, ctx, 0, seen)
|
|
779
|
+
for code, msg in res.problems:
|
|
780
|
+
ctx.diag(code, "warning", msg)
|
|
781
|
+
out_i = ctx.col_entity(target_i, tcol, evi, declared=True)
|
|
782
|
+
for tbl_i, cname in res.terminals:
|
|
783
|
+
src_i = ctx.col_entity(tbl_i, cname, evi, declared=False)
|
|
784
|
+
ctx.add_rel(
|
|
785
|
+
DomainRelationKind.DERIVES_FROM.value, out_i,
|
|
786
|
+
to_i=src_i, evi=evi,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _extract_file(ctx: _FileCtx, parsed: ParsedFile) -> None:
|
|
791
|
+
for i, st in enumerate(parsed.statements):
|
|
792
|
+
try:
|
|
793
|
+
_stmt(ctx, i, st)
|
|
794
|
+
except Exception as exc: # nunca crash — parcial honesto
|
|
795
|
+
ctx.diag(
|
|
796
|
+
"extract-error", "error",
|
|
797
|
+
f"{st.kind}#{i}: {type(exc).__name__}: {exc}",
|
|
798
|
+
)
|
|
799
|
+
# problemas do parser viram diagnósticos de domínio
|
|
800
|
+
for d in parsed.diagnostics:
|
|
801
|
+
sev = "error" if d.severity == "error" else "warning"
|
|
802
|
+
ctx.diag(
|
|
803
|
+
d.code if d.code != "error-node" else "parse-error",
|
|
804
|
+
sev, d.message,
|
|
805
|
+
)
|
|
806
|
+
ctx.stats["statements"] = len(parsed.statements)
|
|
807
|
+
ctx.stats["parse_diagnostics"] = len(parsed.diagnostics)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _stmt(ctx: _FileCtx, i: int, st: Statement) -> None:
|
|
811
|
+
evi = ctx.evi(i, st.span)
|
|
812
|
+
if st.dynamic:
|
|
813
|
+
ctx.diag(
|
|
814
|
+
"dynamic-sql", "warning",
|
|
815
|
+
f"{st.kind}#{i} contains dynamic/template content — "
|
|
816
|
+
"only static parts were extracted",
|
|
817
|
+
)
|
|
818
|
+
if isinstance(st, SUnsupported):
|
|
819
|
+
code = (
|
|
820
|
+
"dynamic-sql" if st.why == "dynamic-exec"
|
|
821
|
+
else "unsupported-statement"
|
|
822
|
+
)
|
|
823
|
+
sev = "warning" if st.why == "dynamic-exec" else "info"
|
|
824
|
+
ctx.diag(code, sev, f"statement skipped: {st.head or st.why}")
|
|
825
|
+
return
|
|
826
|
+
if isinstance(st, SUse):
|
|
827
|
+
_use(ctx, st)
|
|
828
|
+
return
|
|
829
|
+
handler = _STMT_HANDLERS.get(type(st))
|
|
830
|
+
if handler is not None:
|
|
831
|
+
handler(ctx, i, st, evi)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _use(ctx: _FileCtx, st: SUse) -> None:
|
|
835
|
+
if len(st.parts) >= 2:
|
|
836
|
+
ctx.use_catalog = st.parts[0]
|
|
837
|
+
ctx.use_schema = ".".join(st.parts[1:])
|
|
838
|
+
elif st.parts:
|
|
839
|
+
ctx.use_schema = st.parts[0]
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _read_edges(
|
|
843
|
+
ctx: _FileCtx,
|
|
844
|
+
tid: DomainIdentity,
|
|
845
|
+
sources: list[SrcTable],
|
|
846
|
+
dyn: list[SrcUnsupported],
|
|
847
|
+
evi: int,
|
|
848
|
+
) -> None:
|
|
849
|
+
for s in sources:
|
|
850
|
+
ti = ctx.table_entity(s.name, evi, declared=False,
|
|
851
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
852
|
+
ctx.add_rel(
|
|
853
|
+
DomainRelationKind.READS_FROM.value, tid,
|
|
854
|
+
to_i=ti, span=s.name.span, evi=evi,
|
|
855
|
+
)
|
|
856
|
+
for d in dyn:
|
|
857
|
+
ctx.add_rel(
|
|
858
|
+
DomainRelationKind.READS_FROM.value, tid,
|
|
859
|
+
to_text=d.written, span=d.span, evi=evi,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _collect(
|
|
864
|
+
ctx: _FileCtx, env: dict[str, Cte], queries: list[SelectStmt]
|
|
865
|
+
) -> tuple[list[SrcTable], list[SrcUnsupported]]:
|
|
866
|
+
tables: list[SrcTable] = []
|
|
867
|
+
dyn: list[SrcUnsupported] = []
|
|
868
|
+
for q in queries:
|
|
869
|
+
env2 = _cte_env(q.ctes, env)
|
|
870
|
+
_query_tables(q, env2, tables, dyn, ctx, 0)
|
|
871
|
+
return tables, dyn
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _derive_edges(
|
|
875
|
+
ctx: _FileCtx,
|
|
876
|
+
target_i: DomainIdentity,
|
|
877
|
+
sources: list[SrcTable],
|
|
878
|
+
evi: int,
|
|
879
|
+
) -> None:
|
|
880
|
+
tkey = target_i.logical_key()
|
|
881
|
+
for s in sources:
|
|
882
|
+
ti = ctx.table_entity(s.name, evi, declared=False,
|
|
883
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
884
|
+
if ti.logical_key() == tkey:
|
|
885
|
+
continue # auto-derivação não é aresta
|
|
886
|
+
ctx.add_rel(
|
|
887
|
+
DomainRelationKind.DERIVES_FROM.value, target_i,
|
|
888
|
+
to_i=ti, span=s.name.span, evi=evi,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _select(ctx: _FileCtx, i: int, st: SSelect, evi: int) -> None:
|
|
893
|
+
q = st.query
|
|
894
|
+
if q is None:
|
|
895
|
+
return
|
|
896
|
+
env = _cte_env(q.ctes)
|
|
897
|
+
tables, dyn = _collect(ctx, env, [q])
|
|
898
|
+
if not tables and not dyn:
|
|
899
|
+
return
|
|
900
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
901
|
+
_read_edges(ctx, tid, tables, dyn, evi)
|
|
902
|
+
_star_diag(ctx, st, q)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _insert(ctx: _FileCtx, i: int, st: SInsert, evi: int) -> None:
|
|
906
|
+
assert st.target is not None
|
|
907
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
908
|
+
tgt = ctx.table_entity(st.target, evi, declared=False,
|
|
909
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
910
|
+
env = _cte_env(st.query.ctes if st.query else [])
|
|
911
|
+
tables, dyn = _collect(ctx, env, [st.query] if st.query else [])
|
|
912
|
+
_read_edges(ctx, tid, tables, dyn, evi)
|
|
913
|
+
ctx.add_rel(
|
|
914
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
915
|
+
to_i=tgt, span=st.target.span, evi=evi,
|
|
916
|
+
)
|
|
917
|
+
_derive_edges(ctx, tgt, tables, evi)
|
|
918
|
+
if st.query is not None:
|
|
919
|
+
_star_diag(ctx, st, st.query)
|
|
920
|
+
if st.columns is None:
|
|
921
|
+
ctx.diag(
|
|
922
|
+
"implicit-column-list", "info",
|
|
923
|
+
"INSERT without column list — target column names "
|
|
924
|
+
"unknown; column lineage skipped",
|
|
925
|
+
)
|
|
926
|
+
else:
|
|
927
|
+
pairs = _positional_pairs(st.columns, st.query)
|
|
928
|
+
if pairs is None:
|
|
929
|
+
ctx.diag(
|
|
930
|
+
"column-count-mismatch", "warning",
|
|
931
|
+
f"INSERT column list ({len(st.columns)}) does not "
|
|
932
|
+
f"match select items — mapped common prefix",
|
|
933
|
+
)
|
|
934
|
+
pairs = _positional_pairs(st.columns, st.query, strict=False)
|
|
935
|
+
scope = _build_scope(
|
|
936
|
+
st.query.sources, env, ctx, None, 0, frozenset()
|
|
937
|
+
)
|
|
938
|
+
_col_lineage(ctx, scope, pairs or [], tgt, evi)
|
|
939
|
+
elif st.value_rows:
|
|
940
|
+
# VALUES literal — sem upstream; subqueries nas linhas são lidas
|
|
941
|
+
sub_tables: list[SrcTable] = []
|
|
942
|
+
sub_dyn: list[SrcUnsupported] = []
|
|
943
|
+
for row in st.value_rows:
|
|
944
|
+
for refs in row:
|
|
945
|
+
for sq in refs.subqueries:
|
|
946
|
+
_query_tables(sq, dict(env), sub_tables, sub_dyn, ctx, 0)
|
|
947
|
+
_read_edges(ctx, tid, sub_tables, sub_dyn, evi)
|
|
948
|
+
if st.columns is None:
|
|
949
|
+
ctx.diag(
|
|
950
|
+
"implicit-column-list", "info",
|
|
951
|
+
"INSERT … VALUES without column list — "
|
|
952
|
+
"column lineage skipped",
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _positional_pairs(
|
|
957
|
+
cols: tuple[str, ...], q: SelectStmt, *, strict: bool = True
|
|
958
|
+
) -> list[tuple[str, ExprRefs]] | None:
|
|
959
|
+
items = q.items
|
|
960
|
+
if strict and len(cols) != len(items):
|
|
961
|
+
return None
|
|
962
|
+
return [
|
|
963
|
+
(c, items[k].refs)
|
|
964
|
+
for k, c in enumerate(cols)
|
|
965
|
+
if k < len(items)
|
|
966
|
+
]
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _update(ctx: _FileCtx, i: int, st: SUpdate, evi: int) -> None:
|
|
970
|
+
assert st.target is not None
|
|
971
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
972
|
+
tgt = ctx.table_entity(st.target, evi, declared=False,
|
|
973
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
974
|
+
env = _cte_env(st.ctes)
|
|
975
|
+
tables: list[SrcTable] = []
|
|
976
|
+
dyn: list[SrcUnsupported] = []
|
|
977
|
+
_srcs_tables(st.sources, env, tables, dyn, ctx, 0)
|
|
978
|
+
for q in st.tail_subqueries:
|
|
979
|
+
_query_tables(q, dict(env), tables, dyn, ctx, 0)
|
|
980
|
+
for _, refs in st.sets:
|
|
981
|
+
for sq in refs.subqueries:
|
|
982
|
+
_query_tables(sq, dict(env), tables, dyn, ctx, 0)
|
|
983
|
+
ctx.add_rel(
|
|
984
|
+
DomainRelationKind.READS_FROM.value, tid,
|
|
985
|
+
to_i=tgt, span=st.target.span, evi=evi,
|
|
986
|
+
)
|
|
987
|
+
_read_edges(ctx, tid, tables, dyn, evi)
|
|
988
|
+
ctx.add_rel(
|
|
989
|
+
DomainRelationKind.TRANSFORMS.value, tid,
|
|
990
|
+
to_i=tgt, span=st.target.span, evi=evi,
|
|
991
|
+
)
|
|
992
|
+
_derive_edges(ctx, tgt, tables, evi)
|
|
993
|
+
# escopo: o próprio alvo + fontes da cláusula FROM
|
|
994
|
+
tsrc = _TblSrc(
|
|
995
|
+
ident=tgt, parts=st.target.parts,
|
|
996
|
+
alias=st.target_alias or st.target.parts[-1],
|
|
997
|
+
span=st.target.span,
|
|
998
|
+
)
|
|
999
|
+
scope = _Scope(sources=[tsrc])
|
|
1000
|
+
for s in st.sources:
|
|
1001
|
+
info = _src_info(s, env, ctx, scope, 0, frozenset())
|
|
1002
|
+
if isinstance(info, list):
|
|
1003
|
+
scope.sources.extend(info)
|
|
1004
|
+
elif info is not None:
|
|
1005
|
+
scope.sources.append(info)
|
|
1006
|
+
_col_lineage(ctx, scope, list(st.sets), tgt, evi)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _delete(ctx: _FileCtx, i: int, st: SDelete, evi: int) -> None:
|
|
1010
|
+
assert st.target is not None
|
|
1011
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
1012
|
+
tgt = ctx.table_entity(st.target, evi, declared=False,
|
|
1013
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
1014
|
+
env = _cte_env(st.ctes)
|
|
1015
|
+
tables: list[SrcTable] = []
|
|
1016
|
+
dyn: list[SrcUnsupported] = []
|
|
1017
|
+
_srcs_tables(st.sources, env, tables, dyn, ctx, 0)
|
|
1018
|
+
for q in st.tail_subqueries:
|
|
1019
|
+
_query_tables(q, dict(env), tables, dyn, ctx, 0)
|
|
1020
|
+
ctx.add_rel(
|
|
1021
|
+
DomainRelationKind.READS_FROM.value, tid,
|
|
1022
|
+
to_i=tgt, span=st.target.span, evi=evi,
|
|
1023
|
+
)
|
|
1024
|
+
_read_edges(ctx, tid, tables, dyn, evi)
|
|
1025
|
+
ctx.add_rel(
|
|
1026
|
+
DomainRelationKind.TRANSFORMS.value, tid,
|
|
1027
|
+
to_i=tgt, span=st.target.span, evi=evi,
|
|
1028
|
+
)
|
|
1029
|
+
_derive_edges(ctx, tgt, tables, evi)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _merge(ctx: _FileCtx, i: int, st: SMerge, evi: int) -> None:
|
|
1033
|
+
assert st.target is not None and st.source is not None
|
|
1034
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
1035
|
+
tgt = ctx.table_entity(st.target, evi, declared=False,
|
|
1036
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
1037
|
+
env = _cte_env(st.ctes)
|
|
1038
|
+
tables: list[SrcTable] = []
|
|
1039
|
+
dyn: list[SrcUnsupported] = []
|
|
1040
|
+
_srcs_tables([st.source], env, tables, dyn, ctx, 0)
|
|
1041
|
+
for q in st.tail_subqueries:
|
|
1042
|
+
_query_tables(q, dict(env), tables, dyn, ctx, 0)
|
|
1043
|
+
for _, refs in st.sets:
|
|
1044
|
+
for sq in refs.subqueries:
|
|
1045
|
+
_query_tables(sq, dict(env), tables, dyn, ctx, 0)
|
|
1046
|
+
for refs in st.insert_exprs:
|
|
1047
|
+
for sq in refs.subqueries:
|
|
1048
|
+
_query_tables(sq, dict(env), tables, dyn, ctx, 0)
|
|
1049
|
+
ctx.add_rel(
|
|
1050
|
+
DomainRelationKind.READS_FROM.value, tid,
|
|
1051
|
+
to_i=tgt, span=st.target.span, evi=evi,
|
|
1052
|
+
)
|
|
1053
|
+
_read_edges(ctx, tid, tables, dyn, evi)
|
|
1054
|
+
ctx.add_rel(
|
|
1055
|
+
DomainRelationKind.TRANSFORMS.value, tid,
|
|
1056
|
+
to_i=tgt, span=st.target.span, evi=evi,
|
|
1057
|
+
)
|
|
1058
|
+
_derive_edges(ctx, tgt, tables, evi)
|
|
1059
|
+
# escopo: alvo + fonte do USING
|
|
1060
|
+
scope = _Scope(sources=[])
|
|
1061
|
+
scope.sources.append(
|
|
1062
|
+
_TblSrc(
|
|
1063
|
+
ident=tgt, parts=st.target.parts,
|
|
1064
|
+
alias=st.target_alias or st.target.parts[-1],
|
|
1065
|
+
span=st.target.span,
|
|
1066
|
+
)
|
|
1067
|
+
)
|
|
1068
|
+
info = _src_info(st.source, env, ctx, scope, 0, frozenset())
|
|
1069
|
+
if isinstance(info, list):
|
|
1070
|
+
scope.sources.extend(info)
|
|
1071
|
+
elif info is not None:
|
|
1072
|
+
scope.sources.append(info)
|
|
1073
|
+
_col_lineage(ctx, scope, list(st.sets), tgt, evi)
|
|
1074
|
+
if st.insert_cols is not None and st.insert_exprs:
|
|
1075
|
+
pairs = [
|
|
1076
|
+
(c, st.insert_exprs[k])
|
|
1077
|
+
for k, c in enumerate(st.insert_cols)
|
|
1078
|
+
if k < len(st.insert_exprs)
|
|
1079
|
+
]
|
|
1080
|
+
if len(st.insert_cols) != len(st.insert_exprs):
|
|
1081
|
+
ctx.diag(
|
|
1082
|
+
"column-count-mismatch", "warning",
|
|
1083
|
+
"MERGE INSERT column list does not match VALUES arity",
|
|
1084
|
+
)
|
|
1085
|
+
_col_lineage(ctx, scope, pairs, tgt, evi)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _create_table(ctx: _FileCtx, i: int, st: SCreateTable, evi: int) -> None:
|
|
1089
|
+
assert st.name is not None
|
|
1090
|
+
tgt = ctx.table_entity(st.name, evi, declared=True,
|
|
1091
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
1092
|
+
for cname, ctype in st.columns:
|
|
1093
|
+
ident = ctx.col_ident(tgt, cname)
|
|
1094
|
+
ctx.add_entity(
|
|
1095
|
+
ident, DomainEntityKind.DATA_COLUMN, cname, None,
|
|
1096
|
+
{
|
|
1097
|
+
"table": tgt.display(),
|
|
1098
|
+
"declared_type": ctype,
|
|
1099
|
+
"dialect": ctx.dialect,
|
|
1100
|
+
"declared": "create",
|
|
1101
|
+
},
|
|
1102
|
+
prio=3, evi=evi,
|
|
1103
|
+
)
|
|
1104
|
+
if st.like is not None:
|
|
1105
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
1106
|
+
li = ctx.table_entity(st.like, evi, declared=False,
|
|
1107
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
1108
|
+
ctx.add_rel(
|
|
1109
|
+
DomainRelationKind.READS_FROM.value, tid,
|
|
1110
|
+
to_i=li, span=st.like.span, evi=evi,
|
|
1111
|
+
)
|
|
1112
|
+
ctx.add_rel(
|
|
1113
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1114
|
+
to_i=tgt, span=st.name.span, evi=evi,
|
|
1115
|
+
)
|
|
1116
|
+
ctx.add_rel(
|
|
1117
|
+
DomainRelationKind.DERIVES_FROM.value, tgt,
|
|
1118
|
+
to_i=li, span=st.like.span, evi=evi,
|
|
1119
|
+
)
|
|
1120
|
+
if st.query is not None:
|
|
1121
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
1122
|
+
env = _cte_env(st.query.ctes)
|
|
1123
|
+
tables, dyn = _collect(ctx, env, [st.query])
|
|
1124
|
+
_read_edges(ctx, tid, tables, dyn, evi)
|
|
1125
|
+
ctx.add_rel(
|
|
1126
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1127
|
+
to_i=tgt, span=st.name.span, evi=evi,
|
|
1128
|
+
)
|
|
1129
|
+
_derive_edges(ctx, tgt, tables, evi)
|
|
1130
|
+
_star_diag(ctx, st, st.query)
|
|
1131
|
+
pairs = _named_pairs(ctx, st.query)
|
|
1132
|
+
scope = _build_scope(
|
|
1133
|
+
st.query.sources, env, ctx, None, 0, frozenset()
|
|
1134
|
+
)
|
|
1135
|
+
_col_lineage(ctx, scope, pairs, tgt, evi)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _create_view(ctx: _FileCtx, i: int, st: SCreateView, evi: int) -> None:
|
|
1139
|
+
assert st.name is not None and st.query is not None
|
|
1140
|
+
tgt = ctx.table_entity(st.name, evi, declared=True,
|
|
1141
|
+
kind=DomainEntityKind.DATA_VIEW)
|
|
1142
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
1143
|
+
env = _cte_env(st.query.ctes)
|
|
1144
|
+
tables, dyn = _collect(ctx, env, [st.query])
|
|
1145
|
+
_read_edges(ctx, tid, tables, dyn, evi)
|
|
1146
|
+
ctx.add_rel(
|
|
1147
|
+
DomainRelationKind.WRITES_TO.value, tid,
|
|
1148
|
+
to_i=tgt, span=st.name.span, evi=evi,
|
|
1149
|
+
)
|
|
1150
|
+
_derive_edges(ctx, tgt, tables, evi)
|
|
1151
|
+
_star_diag(ctx, st, st.query)
|
|
1152
|
+
pairs = _named_pairs(ctx, st.query, col_aliases=st.col_aliases)
|
|
1153
|
+
scope = _build_scope(
|
|
1154
|
+
st.query.sources, env, ctx, None, 0, frozenset()
|
|
1155
|
+
)
|
|
1156
|
+
_col_lineage(ctx, scope, pairs, tgt, evi)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _named_pairs(
|
|
1160
|
+
ctx: _FileCtx,
|
|
1161
|
+
q: SelectStmt,
|
|
1162
|
+
col_aliases: tuple[str, ...] = (),
|
|
1163
|
+
) -> list[tuple[str, ExprRefs]]:
|
|
1164
|
+
"""Pares (coluna-saída, refs) por nome — VIEW/CTAS.
|
|
1165
|
+
|
|
1166
|
+
Nome vem de col_aliases posicionais, alias do item ou colref simples.
|
|
1167
|
+
Itens sem nome → diagnóstico ``unnamed-output`` e skip.
|
|
1168
|
+
"""
|
|
1169
|
+
pairs: list[tuple[str, ExprRefs]] = []
|
|
1170
|
+
for k, item in enumerate(q.items):
|
|
1171
|
+
name: str | None = None
|
|
1172
|
+
if k < len(col_aliases):
|
|
1173
|
+
name = col_aliases[k]
|
|
1174
|
+
if name is None:
|
|
1175
|
+
name = item.alias
|
|
1176
|
+
if name is None and len(item.refs.cols) == 1 and not item.refs.stars:
|
|
1177
|
+
name = item.refs.cols[0].parts[-1]
|
|
1178
|
+
if name is None:
|
|
1179
|
+
if item.star is None:
|
|
1180
|
+
ctx.diag(
|
|
1181
|
+
"unnamed-output", "info",
|
|
1182
|
+
"output column has no resolvable name — "
|
|
1183
|
+
"column lineage skipped for it",
|
|
1184
|
+
)
|
|
1185
|
+
continue
|
|
1186
|
+
pairs.append((name, item.refs))
|
|
1187
|
+
return pairs
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _truncate(ctx: _FileCtx, i: int, st: STruncate, evi: int) -> None:
|
|
1191
|
+
assert st.name is not None
|
|
1192
|
+
tid = ctx.transform_entity(i, st, evi)
|
|
1193
|
+
tgt = ctx.table_entity(st.name, evi, declared=False,
|
|
1194
|
+
kind=DomainEntityKind.DATA_TABLE)
|
|
1195
|
+
ctx.add_rel(
|
|
1196
|
+
DomainRelationKind.TRANSFORMS.value, tid,
|
|
1197
|
+
to_i=tgt, span=st.name.span, evi=evi,
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
# Dispatch por tipo de statement — ``_use``/``SUnsupported`` ficam fora
|
|
1202
|
+
# porque têm assinatura e semântica próprias (sem evidência de statement).
|
|
1203
|
+
_STMT_HANDLERS: dict[
|
|
1204
|
+
type[Statement], Callable[[_FileCtx, int, Any, int], None]
|
|
1205
|
+
] = {
|
|
1206
|
+
SSelect: _select,
|
|
1207
|
+
SInsert: _insert,
|
|
1208
|
+
SUpdate: _update,
|
|
1209
|
+
SDelete: _delete,
|
|
1210
|
+
SMerge: _merge,
|
|
1211
|
+
SCreateTable: _create_table,
|
|
1212
|
+
SCreateView: _create_view,
|
|
1213
|
+
STruncate: _truncate,
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
# --- ponto de entrada --------------------------------------------------------
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
@dataclass
|
|
1221
|
+
class FileExtraction:
|
|
1222
|
+
entities: list[ExtractedDomainEntity]
|
|
1223
|
+
relations: list[ExtractedDomainRelation]
|
|
1224
|
+
evidence: list[EvidenceRecord]
|
|
1225
|
+
diagnostics: list[DomainDiagnostic]
|
|
1226
|
+
stats: dict[str, int]
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def extract_parsed(
|
|
1230
|
+
parsed: ParsedFile,
|
|
1231
|
+
*,
|
|
1232
|
+
repository: str,
|
|
1233
|
+
root: str,
|
|
1234
|
+
environment: str,
|
|
1235
|
+
path: str,
|
|
1236
|
+
sha: str,
|
|
1237
|
+
adapter_version: str,
|
|
1238
|
+
) -> FileExtraction:
|
|
1239
|
+
"""Converte ``ParsedFile`` em fatos de domínio de um artefato."""
|
|
1240
|
+
ctx = _FileCtx(
|
|
1241
|
+
repository=repository, root=root, environment=environment,
|
|
1242
|
+
path=path, sha=sha, dialect=parsed.dialect,
|
|
1243
|
+
adapter_version=adapter_version,
|
|
1244
|
+
)
|
|
1245
|
+
_extract_file(ctx, parsed)
|
|
1246
|
+
ctx.finish_diags()
|
|
1247
|
+
entities = [
|
|
1248
|
+
ExtractedDomainEntity(
|
|
1249
|
+
identity=acc.identity, kind=acc.kind, name=acc.name,
|
|
1250
|
+
path=path, span=acc.span, content_hash=sha,
|
|
1251
|
+
attributes=acc.attrs, evidence=tuple(sorted(acc.ev)),
|
|
1252
|
+
)
|
|
1253
|
+
for acc in ctx.entities.values()
|
|
1254
|
+
]
|
|
1255
|
+
relations = [
|
|
1256
|
+
ExtractedDomainRelation(
|
|
1257
|
+
relation_type=acc.rtype, path=path,
|
|
1258
|
+
from_identity=acc.from_i, to_identity=acc.to_i,
|
|
1259
|
+
to_unresolved=acc.to_text, span=acc.span,
|
|
1260
|
+
evidence=tuple(sorted(acc.ev)),
|
|
1261
|
+
)
|
|
1262
|
+
for acc in ctx.relations.values()
|
|
1263
|
+
]
|
|
1264
|
+
stats = dict(ctx.stats)
|
|
1265
|
+
stats["entities"] = len(entities)
|
|
1266
|
+
stats["relations"] = len(relations)
|
|
1267
|
+
stats["diagnostics"] = len(ctx.diagnostics)
|
|
1268
|
+
return FileExtraction(
|
|
1269
|
+
entities=entities, relations=relations,
|
|
1270
|
+
evidence=ctx.evidence, diagnostics=ctx.diagnostics, stats=stats,
|
|
1271
|
+
)
|