pretensor 0.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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Downstream graph impact for a batch of :class:`SchemaChange` rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from pretensor.connectors.snapshot import ChangeTarget, SchemaChange
|
|
9
|
+
from pretensor.core.ids import table_node_id
|
|
10
|
+
from pretensor.core.store import KuzuStore
|
|
11
|
+
|
|
12
|
+
__all__ = ["ImpactAnalyzer", "ImpactReport"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ImpactReport:
|
|
17
|
+
"""Artifacts that may be stale or invalid after schema changes."""
|
|
18
|
+
|
|
19
|
+
stale_join_path_ids: list[str] = field(default_factory=list)
|
|
20
|
+
stale_join_path_labels: list[str] = field(default_factory=list)
|
|
21
|
+
affected_cluster_labels: list[str] = field(default_factory=list)
|
|
22
|
+
broken_metrics: list[str] = field(default_factory=list)
|
|
23
|
+
summary: str = ""
|
|
24
|
+
|
|
25
|
+
def to_impact_dict(self) -> dict[str, object]:
|
|
26
|
+
"""Shape used by MCP ``detect_changes``."""
|
|
27
|
+
return {
|
|
28
|
+
"stale_join_paths": len(self.stale_join_path_ids),
|
|
29
|
+
"affected_clusters": list(dict.fromkeys(self.affected_cluster_labels)),
|
|
30
|
+
"broken_metrics": list(self.broken_metrics),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ImpactAnalyzer:
|
|
35
|
+
"""Find join paths and clusters affected by schema drift (read-only)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, store: KuzuStore) -> None:
|
|
38
|
+
self._store = store
|
|
39
|
+
|
|
40
|
+
def analyze(
|
|
41
|
+
self,
|
|
42
|
+
changes: list[SchemaChange],
|
|
43
|
+
*,
|
|
44
|
+
connection_name: str,
|
|
45
|
+
database_key: str,
|
|
46
|
+
) -> ImpactReport:
|
|
47
|
+
"""Return join paths and clusters touching changed tables/columns."""
|
|
48
|
+
report = ImpactReport()
|
|
49
|
+
if not changes:
|
|
50
|
+
report.summary = "No schema changes."
|
|
51
|
+
return report
|
|
52
|
+
|
|
53
|
+
affected_tables: set[tuple[str, str]] = set()
|
|
54
|
+
affected_columns: set[tuple[str, str, str]] = set()
|
|
55
|
+
for ch in changes:
|
|
56
|
+
affected_tables.add((ch.schema_name, ch.table_name))
|
|
57
|
+
if ch.target == ChangeTarget.COLUMN and ch.column_name:
|
|
58
|
+
affected_columns.add((ch.schema_name, ch.table_name, ch.column_name))
|
|
59
|
+
|
|
60
|
+
table_ids = {table_node_id(connection_name, s, t) for s, t in affected_tables}
|
|
61
|
+
|
|
62
|
+
path_rows = self._store.query_all_rows(
|
|
63
|
+
"""
|
|
64
|
+
MATCH (p:JoinPath)
|
|
65
|
+
WHERE p.database_key = $db
|
|
66
|
+
RETURN p.node_id, p.semantic_label, p.from_table_id, p.to_table_id,
|
|
67
|
+
p.steps_json, p.stale
|
|
68
|
+
""",
|
|
69
|
+
{"db": database_key},
|
|
70
|
+
)
|
|
71
|
+
seen_paths: set[str] = set()
|
|
72
|
+
for row in path_rows:
|
|
73
|
+
pid, label, from_id, to_id, steps_json, _stale = row
|
|
74
|
+
pid_s = str(pid)
|
|
75
|
+
if self._path_is_affected(
|
|
76
|
+
pid_s,
|
|
77
|
+
str(from_id or ""),
|
|
78
|
+
str(to_id or ""),
|
|
79
|
+
str(steps_json or ""),
|
|
80
|
+
table_ids,
|
|
81
|
+
affected_tables,
|
|
82
|
+
affected_columns,
|
|
83
|
+
):
|
|
84
|
+
if pid_s not in seen_paths:
|
|
85
|
+
seen_paths.add(pid_s)
|
|
86
|
+
report.stale_join_path_ids.append(pid_s)
|
|
87
|
+
report.stale_join_path_labels.append(str(label or pid_s))
|
|
88
|
+
|
|
89
|
+
cluster_labels: set[str] = set()
|
|
90
|
+
for schema_name, table_name in affected_tables:
|
|
91
|
+
rows = self._store.query_all_rows(
|
|
92
|
+
"""
|
|
93
|
+
MATCH (t:SchemaTable)-[:IN_CLUSTER]->(c:Cluster)
|
|
94
|
+
WHERE t.database = $db AND t.schema_name = $sn AND t.table_name = $tn
|
|
95
|
+
RETURN c.label
|
|
96
|
+
""",
|
|
97
|
+
{"db": database_key, "sn": schema_name, "tn": table_name},
|
|
98
|
+
)
|
|
99
|
+
for (lab,) in rows:
|
|
100
|
+
if lab:
|
|
101
|
+
cluster_labels.add(str(lab))
|
|
102
|
+
report.affected_cluster_labels = sorted(cluster_labels)
|
|
103
|
+
|
|
104
|
+
parts: list[str] = []
|
|
105
|
+
if report.stale_join_path_ids:
|
|
106
|
+
parts.append(
|
|
107
|
+
f"{len(report.stale_join_path_ids)} precomputed join path(s) may be "
|
|
108
|
+
"invalid or stale; verify before using in SQL."
|
|
109
|
+
)
|
|
110
|
+
if report.affected_cluster_labels:
|
|
111
|
+
parts.append(
|
|
112
|
+
f"{len(report.affected_cluster_labels)} cluster(s) may need "
|
|
113
|
+
"re-labeling after membership changes."
|
|
114
|
+
)
|
|
115
|
+
if not parts:
|
|
116
|
+
report.summary = (
|
|
117
|
+
"Schema changes are additive or non-breaking for indexed join paths "
|
|
118
|
+
"and clusters."
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
report.summary = " ".join(parts)
|
|
122
|
+
report.summary += (
|
|
123
|
+
" Run `pretensor reindex <dsn>` to refresh the graph; use "
|
|
124
|
+
"`pretensor index` for a full rebuild if needed."
|
|
125
|
+
)
|
|
126
|
+
return report
|
|
127
|
+
|
|
128
|
+
def _path_is_affected(
|
|
129
|
+
self,
|
|
130
|
+
_path_id: str,
|
|
131
|
+
from_id: str,
|
|
132
|
+
to_id: str,
|
|
133
|
+
steps_json: str,
|
|
134
|
+
table_ids: set[str],
|
|
135
|
+
affected_tables: set[tuple[str, str]],
|
|
136
|
+
affected_columns: set[tuple[str, str, str]],
|
|
137
|
+
) -> bool:
|
|
138
|
+
if from_id in table_ids or to_id in table_ids:
|
|
139
|
+
return True
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(steps_json)
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
return False
|
|
144
|
+
if not isinstance(data, list):
|
|
145
|
+
return False
|
|
146
|
+
for step in data:
|
|
147
|
+
if not isinstance(step, dict):
|
|
148
|
+
continue
|
|
149
|
+
fs = str(step.get("from_schema", ""))
|
|
150
|
+
ft = str(step.get("from_table", ""))
|
|
151
|
+
ts_ = str(step.get("to_schema", ""))
|
|
152
|
+
tt = str(step.get("to_table", ""))
|
|
153
|
+
fc = str(step.get("from_column", ""))
|
|
154
|
+
tc = str(step.get("to_column", ""))
|
|
155
|
+
if (fs, ft) in affected_tables or (ts_, tt) in affected_tables:
|
|
156
|
+
return True
|
|
157
|
+
for sn, tn, cn in affected_columns:
|
|
158
|
+
if (fs, ft) == (sn, tn) and (fc == cn or tc == cn):
|
|
159
|
+
return True
|
|
160
|
+
if (ts_, tt) == (sn, tn) and (tc == cn or fc == cn):
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Persist schema snapshots next to the graph registry for drift detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pretensor.connectors.models import SchemaSnapshot
|
|
8
|
+
|
|
9
|
+
__all__ = ["SnapshotStore"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SnapshotStore:
|
|
13
|
+
"""Read/write ``SchemaSnapshot`` YAML under ``.pretensor/snapshots/``."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, state_dir: Path) -> None:
|
|
16
|
+
self._dir = state_dir / "snapshots"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def snapshots_dir(self) -> Path:
|
|
20
|
+
return self._dir
|
|
21
|
+
|
|
22
|
+
def path_for(self, connection_name: str) -> Path:
|
|
23
|
+
safe = connection_name.replace("/", "_")
|
|
24
|
+
return self._dir / f"{safe}.yaml"
|
|
25
|
+
|
|
26
|
+
def load(self, connection_name: str) -> SchemaSnapshot | None:
|
|
27
|
+
"""Return the last snapshot or None if missing."""
|
|
28
|
+
path = self.path_for(connection_name)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return None
|
|
31
|
+
return SchemaSnapshot.from_yaml(path.read_text(encoding="utf-8"))
|
|
32
|
+
|
|
33
|
+
def save(self, connection_name: str, snapshot: SchemaSnapshot) -> Path:
|
|
34
|
+
"""Write snapshot YAML; creates parent directories."""
|
|
35
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
path = self.path_for(connection_name)
|
|
37
|
+
path.write_text(snapshot.to_yaml(), encoding="utf-8")
|
|
38
|
+
return path
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Parse SQL with sqlglot and validate table/column/join references against Kuzu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from difflib import get_close_matches
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import sqlglot
|
|
10
|
+
from sqlglot import exp
|
|
11
|
+
from sqlglot.errors import ParseError
|
|
12
|
+
|
|
13
|
+
from pretensor.core.store import KuzuStore
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"JoinWarning",
|
|
17
|
+
"QueryValidator",
|
|
18
|
+
"ValidationResult",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_DEFAULT_DIALECT = "postgres"
|
|
22
|
+
_FUZZY_CUTOFF = 0.5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class JoinWarning:
|
|
27
|
+
"""A join whose endpoints are not linked by FK or inferred join in the graph."""
|
|
28
|
+
|
|
29
|
+
message: str
|
|
30
|
+
left_table: str
|
|
31
|
+
right_table: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ValidationResult:
|
|
36
|
+
"""Outcome of semantic validation for a single SQL string."""
|
|
37
|
+
|
|
38
|
+
valid: bool
|
|
39
|
+
syntax_errors: list[str] = field(default_factory=list)
|
|
40
|
+
missing_tables: list[str] = field(default_factory=list)
|
|
41
|
+
missing_columns: list[str] = field(default_factory=list)
|
|
42
|
+
invalid_joins: list[JoinWarning] = field(default_factory=list)
|
|
43
|
+
suggestions: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class QueryValidator:
|
|
47
|
+
"""Validate SQL against ``SchemaTable`` / ``SchemaColumn`` / join edges in Kuzu."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
store: KuzuStore,
|
|
52
|
+
*,
|
|
53
|
+
connection_name: str,
|
|
54
|
+
database_key: str,
|
|
55
|
+
dialect: str = _DEFAULT_DIALECT,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Args:
|
|
58
|
+
store: Open Kuzu store for one indexed connection.
|
|
59
|
+
connection_name: ``SchemaTable.connection_name`` filter (registry connection).
|
|
60
|
+
database_key: ``SchemaTable.database`` filter (logical DB; same as MCP ``db`` key).
|
|
61
|
+
dialect: sqlglot dialect for parsing (default PostgreSQL).
|
|
62
|
+
"""
|
|
63
|
+
self._store = store
|
|
64
|
+
self._connection_name = connection_name
|
|
65
|
+
self._database_key = database_key
|
|
66
|
+
self._dialect = dialect
|
|
67
|
+
|
|
68
|
+
def validate(self, sql: str) -> ValidationResult:
|
|
69
|
+
"""Parse SQL, then check tables, columns, and join relationships in the graph."""
|
|
70
|
+
try:
|
|
71
|
+
parsed = sqlglot.parse_one(sql, dialect=self._dialect)
|
|
72
|
+
except ParseError as exc:
|
|
73
|
+
return ValidationResult(valid=False, syntax_errors=[str(exc)])
|
|
74
|
+
|
|
75
|
+
syntax_errors: list[str] = []
|
|
76
|
+
missing_tables: list[str] = []
|
|
77
|
+
missing_columns: list[str] = []
|
|
78
|
+
invalid_joins: list[JoinWarning] = []
|
|
79
|
+
suggestions: list[str] = []
|
|
80
|
+
|
|
81
|
+
alias_map = _build_alias_map(parsed)
|
|
82
|
+
tables_in_query = _tables_referenced_in_query(parsed)
|
|
83
|
+
|
|
84
|
+
physical_by_qual: dict[str, tuple[str, str]] = {}
|
|
85
|
+
for qual in sorted(tables_in_query):
|
|
86
|
+
phys = self._resolve_physical_table(qual)
|
|
87
|
+
if phys is None:
|
|
88
|
+
missing_tables.append(qual)
|
|
89
|
+
suggestions.extend(self._suggest_tables(qual))
|
|
90
|
+
else:
|
|
91
|
+
physical_by_qual[qual] = phys
|
|
92
|
+
|
|
93
|
+
if missing_tables:
|
|
94
|
+
return ValidationResult(
|
|
95
|
+
valid=False,
|
|
96
|
+
syntax_errors=syntax_errors,
|
|
97
|
+
missing_tables=missing_tables,
|
|
98
|
+
missing_columns=missing_columns,
|
|
99
|
+
invalid_joins=invalid_joins,
|
|
100
|
+
suggestions=suggestions,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
for column_ref in _iter_column_refs(parsed):
|
|
104
|
+
table_qual = _column_table_qualifier(
|
|
105
|
+
column_ref, alias_map, physical_by_qual
|
|
106
|
+
)
|
|
107
|
+
if table_qual is None:
|
|
108
|
+
continue
|
|
109
|
+
if table_qual not in physical_by_qual:
|
|
110
|
+
continue
|
|
111
|
+
schema_name, table_name = physical_by_qual[table_qual]
|
|
112
|
+
col_name = column_ref.name
|
|
113
|
+
if not self._column_exists(schema_name, table_name, col_name):
|
|
114
|
+
key = f"{schema_name}.{table_name}.{col_name}"
|
|
115
|
+
missing_columns.append(key)
|
|
116
|
+
hint = self._suggest_column(schema_name, table_name, col_name)
|
|
117
|
+
if hint:
|
|
118
|
+
suggestions.append(
|
|
119
|
+
f"column `{schema_name}.{table_name}.{col_name}` not found in graph; "
|
|
120
|
+
f"did you mean `{hint}`?"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
seen_bad_pairs: set[tuple[str, str]] = set()
|
|
124
|
+
for join in _iter_joins(parsed):
|
|
125
|
+
on_expr = join.args.get("on")
|
|
126
|
+
if on_expr is None:
|
|
127
|
+
continue
|
|
128
|
+
physical_tables: set[tuple[str, str]] = set()
|
|
129
|
+
for col in on_expr.find_all(exp.Column):
|
|
130
|
+
tq = _column_table_qualifier(col, alias_map, physical_by_qual)
|
|
131
|
+
if tq is None or tq not in physical_by_qual:
|
|
132
|
+
continue
|
|
133
|
+
physical_tables.add(physical_by_qual[tq])
|
|
134
|
+
if len(physical_tables) < 2:
|
|
135
|
+
continue
|
|
136
|
+
pairs = list(physical_tables)
|
|
137
|
+
for i in range(len(pairs)):
|
|
138
|
+
for j in range(i + 1, len(pairs)):
|
|
139
|
+
a, b = pairs[i], pairs[j]
|
|
140
|
+
if self._tables_joinable(a, b):
|
|
141
|
+
continue
|
|
142
|
+
left_qual = f"{a[0]}.{a[1]}"
|
|
143
|
+
right_qual = f"{b[0]}.{b[1]}"
|
|
144
|
+
ordered = sorted((left_qual, right_qual))
|
|
145
|
+
pair_key: tuple[str, str] = (ordered[0], ordered[1])
|
|
146
|
+
if pair_key in seen_bad_pairs:
|
|
147
|
+
continue
|
|
148
|
+
seen_bad_pairs.add(pair_key)
|
|
149
|
+
left_s, left_t = a
|
|
150
|
+
right_s, right_t = b
|
|
151
|
+
invalid_joins.append(
|
|
152
|
+
JoinWarning(
|
|
153
|
+
message=(
|
|
154
|
+
f"no FK_REFERENCES or INFERRED_JOIN in graph between "
|
|
155
|
+
f"`{left_s}.{left_t}` and `{right_s}.{right_t}`"
|
|
156
|
+
),
|
|
157
|
+
left_table=f"{left_s}.{left_t}",
|
|
158
|
+
right_table=f"{right_s}.{right_t}",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
alt = self._join_suggestion(a, b)
|
|
162
|
+
if alt:
|
|
163
|
+
suggestions.append(alt)
|
|
164
|
+
|
|
165
|
+
has_semantic_issue = bool(missing_columns or invalid_joins)
|
|
166
|
+
return ValidationResult(
|
|
167
|
+
valid=not has_semantic_issue,
|
|
168
|
+
syntax_errors=syntax_errors,
|
|
169
|
+
missing_tables=missing_tables,
|
|
170
|
+
missing_columns=missing_columns,
|
|
171
|
+
invalid_joins=invalid_joins,
|
|
172
|
+
suggestions=suggestions,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _resolve_physical_table(self, qualified: str) -> tuple[str, str] | None:
|
|
176
|
+
"""Map ``schema.table`` or bare ``table`` to ``(schema_name, table_name)`` in Kuzu."""
|
|
177
|
+
if "." in qualified:
|
|
178
|
+
schema_name, _, table_name = qualified.partition(".")
|
|
179
|
+
rows = self._store.query_all_rows(
|
|
180
|
+
"""
|
|
181
|
+
MATCH (t:SchemaTable)
|
|
182
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
183
|
+
AND t.schema_name = $sn AND t.table_name = $tn
|
|
184
|
+
RETURN t.schema_name, t.table_name
|
|
185
|
+
LIMIT 1
|
|
186
|
+
""",
|
|
187
|
+
{
|
|
188
|
+
"cn": self._connection_name,
|
|
189
|
+
"db": self._database_key,
|
|
190
|
+
"sn": schema_name,
|
|
191
|
+
"tn": table_name,
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
rows = self._store.query_all_rows(
|
|
196
|
+
"""
|
|
197
|
+
MATCH (t:SchemaTable)
|
|
198
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
199
|
+
AND t.table_name = $tbl
|
|
200
|
+
RETURN t.schema_name, t.table_name
|
|
201
|
+
""",
|
|
202
|
+
{
|
|
203
|
+
"cn": self._connection_name,
|
|
204
|
+
"db": self._database_key,
|
|
205
|
+
"tbl": qualified,
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
if len(rows) > 1:
|
|
209
|
+
return None
|
|
210
|
+
if not rows:
|
|
211
|
+
return None
|
|
212
|
+
sn, tn = rows[0]
|
|
213
|
+
return (str(sn), str(tn))
|
|
214
|
+
|
|
215
|
+
def _column_exists(
|
|
216
|
+
self, schema_name: str, table_name: str, column_name: str
|
|
217
|
+
) -> bool:
|
|
218
|
+
rows = self._store.query_all_rows(
|
|
219
|
+
"""
|
|
220
|
+
MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
|
|
221
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
222
|
+
AND t.schema_name = $sn AND t.table_name = $tn
|
|
223
|
+
AND c.column_name = $col
|
|
224
|
+
RETURN 1 LIMIT 1
|
|
225
|
+
""",
|
|
226
|
+
{
|
|
227
|
+
"cn": self._connection_name,
|
|
228
|
+
"db": self._database_key,
|
|
229
|
+
"sn": schema_name,
|
|
230
|
+
"tn": table_name,
|
|
231
|
+
"col": column_name,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
return bool(rows)
|
|
235
|
+
|
|
236
|
+
def _tables_joinable(
|
|
237
|
+
self,
|
|
238
|
+
left: tuple[str, str],
|
|
239
|
+
right: tuple[str, str],
|
|
240
|
+
) -> bool:
|
|
241
|
+
rows = self._store.query_all_rows(
|
|
242
|
+
"""
|
|
243
|
+
MATCH (a:SchemaTable), (b:SchemaTable)
|
|
244
|
+
WHERE a.connection_name = $cn AND a.database = $db
|
|
245
|
+
AND b.connection_name = $cn AND b.database = $db
|
|
246
|
+
AND a.schema_name = $lsa AND a.table_name = $lta
|
|
247
|
+
AND b.schema_name = $rsb AND b.table_name = $rtb
|
|
248
|
+
AND (
|
|
249
|
+
(a)-[:FK_REFERENCES]->(b) OR (b)-[:FK_REFERENCES]->(a)
|
|
250
|
+
OR (a)-[:INFERRED_JOIN]->(b) OR (b)-[:INFERRED_JOIN]->(a)
|
|
251
|
+
)
|
|
252
|
+
RETURN count(*) AS n
|
|
253
|
+
""",
|
|
254
|
+
{
|
|
255
|
+
"cn": self._connection_name,
|
|
256
|
+
"db": self._database_key,
|
|
257
|
+
"lsa": left[0],
|
|
258
|
+
"lta": left[1],
|
|
259
|
+
"rsb": right[0],
|
|
260
|
+
"rtb": right[1],
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
if not rows:
|
|
264
|
+
return False
|
|
265
|
+
n = rows[0][0]
|
|
266
|
+
return int(n) > 0 if n is not None else False
|
|
267
|
+
|
|
268
|
+
def _suggest_tables(self, qualified: str) -> list[str]:
|
|
269
|
+
if "." in qualified:
|
|
270
|
+
_, _, tbl = qualified.partition(".")
|
|
271
|
+
else:
|
|
272
|
+
tbl = qualified
|
|
273
|
+
rows = self._store.query_all_rows(
|
|
274
|
+
"""
|
|
275
|
+
MATCH (t:SchemaTable)
|
|
276
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
277
|
+
RETURN t.schema_name, t.table_name
|
|
278
|
+
""",
|
|
279
|
+
{"cn": self._connection_name, "db": self._database_key},
|
|
280
|
+
)
|
|
281
|
+
names = [f"{sn}.{tn}" for sn, tn in rows]
|
|
282
|
+
matches = get_close_matches(
|
|
283
|
+
tbl, [r[1] for r in rows], n=3, cutoff=_FUZZY_CUTOFF
|
|
284
|
+
)
|
|
285
|
+
out: list[str] = []
|
|
286
|
+
for m in matches:
|
|
287
|
+
for sn, tn in rows:
|
|
288
|
+
if str(tn) == m:
|
|
289
|
+
out.append(
|
|
290
|
+
f"table `{qualified}` not in graph; did you mean `{sn}.{tn}`?"
|
|
291
|
+
)
|
|
292
|
+
break
|
|
293
|
+
if not out and names:
|
|
294
|
+
out.append(
|
|
295
|
+
f"table `{qualified}` not in graph; known tables include "
|
|
296
|
+
f"{', '.join(names[:5])}{'…' if len(names) > 5 else ''}"
|
|
297
|
+
)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
def _suggest_column(
|
|
301
|
+
self, schema_name: str, table_name: str, column_name: str
|
|
302
|
+
) -> str | None:
|
|
303
|
+
rows = self._store.query_all_rows(
|
|
304
|
+
"""
|
|
305
|
+
MATCH (t:SchemaTable)-[:HAS_COLUMN]->(c:SchemaColumn)
|
|
306
|
+
WHERE t.connection_name = $cn AND t.database = $db
|
|
307
|
+
AND t.schema_name = $sn AND t.table_name = $tn
|
|
308
|
+
RETURN c.column_name
|
|
309
|
+
""",
|
|
310
|
+
{
|
|
311
|
+
"cn": self._connection_name,
|
|
312
|
+
"db": self._database_key,
|
|
313
|
+
"sn": schema_name,
|
|
314
|
+
"tn": table_name,
|
|
315
|
+
},
|
|
316
|
+
)
|
|
317
|
+
names = [str(r[0]) for r in rows]
|
|
318
|
+
matches = get_close_matches(column_name, names, n=1, cutoff=_FUZZY_CUTOFF)
|
|
319
|
+
if not matches:
|
|
320
|
+
return None
|
|
321
|
+
return f"{schema_name}.{table_name}.{matches[0]}"
|
|
322
|
+
|
|
323
|
+
def _join_suggestion(
|
|
324
|
+
self, left: tuple[str, str], right: tuple[str, str]
|
|
325
|
+
) -> str | None:
|
|
326
|
+
"""If a single-hop path exists via an intermediate table, mention it."""
|
|
327
|
+
rows = self._store.query_all_rows(
|
|
328
|
+
"""
|
|
329
|
+
MATCH (a:SchemaTable), (b:SchemaTable), (mid:SchemaTable)
|
|
330
|
+
WHERE a.connection_name = $cn AND a.database = $db
|
|
331
|
+
AND b.connection_name = $cn AND b.database = $db
|
|
332
|
+
AND mid.connection_name = $cn AND mid.database = $db
|
|
333
|
+
AND a.schema_name = $lsa AND a.table_name = $lta
|
|
334
|
+
AND b.schema_name = $rsb AND b.table_name = $rtb
|
|
335
|
+
AND a <> mid AND b <> mid AND a <> b
|
|
336
|
+
AND (
|
|
337
|
+
((a)-[:FK_REFERENCES|INFERRED_JOIN]-(mid)) AND
|
|
338
|
+
((mid)-[:FK_REFERENCES|INFERRED_JOIN]-(b))
|
|
339
|
+
)
|
|
340
|
+
RETURN mid.schema_name, mid.table_name
|
|
341
|
+
LIMIT 1
|
|
342
|
+
""",
|
|
343
|
+
{
|
|
344
|
+
"cn": self._connection_name,
|
|
345
|
+
"db": self._database_key,
|
|
346
|
+
"lsa": left[0],
|
|
347
|
+
"lta": left[1],
|
|
348
|
+
"rsb": right[0],
|
|
349
|
+
"rtb": right[1],
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
if not rows:
|
|
353
|
+
return None
|
|
354
|
+
ms, mt = rows[0]
|
|
355
|
+
return (
|
|
356
|
+
f"no direct graph edge between `{left[0]}.{left[1]}` and `{right[0]}.{right[1]}`; "
|
|
357
|
+
f"try joining through `{ms}.{mt}` if that matches your model"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _build_alias_map(root: Any) -> dict[str, str]:
|
|
362
|
+
"""Map SQL alias → qualified ``schema.table`` or ``table`` string."""
|
|
363
|
+
mapping: dict[str, str] = {}
|
|
364
|
+
for table in root.find_all(exp.Table):
|
|
365
|
+
qual = _table_qualifier(table)
|
|
366
|
+
alias = _table_alias(table)
|
|
367
|
+
if alias:
|
|
368
|
+
mapping[alias.lower()] = qual
|
|
369
|
+
mapping[qual.lower()] = qual
|
|
370
|
+
return mapping
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _table_qualifier(table: exp.Table) -> str:
|
|
374
|
+
table_name = table.name
|
|
375
|
+
db = table.args.get("db")
|
|
376
|
+
schema_name = db.name if isinstance(db, exp.Identifier) else None
|
|
377
|
+
if schema_name:
|
|
378
|
+
return f"{schema_name}.{table_name}"
|
|
379
|
+
return table_name
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _table_alias(table: exp.Table) -> str | None:
|
|
383
|
+
alias = table.args.get("alias")
|
|
384
|
+
if alias is None:
|
|
385
|
+
return None
|
|
386
|
+
if isinstance(alias, exp.TableAlias):
|
|
387
|
+
ident = alias.this
|
|
388
|
+
if isinstance(ident, exp.Identifier):
|
|
389
|
+
return ident.name
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _tables_referenced_in_query(parsed: Any) -> set[str]:
|
|
394
|
+
seen: set[str] = set()
|
|
395
|
+
for table in parsed.find_all(exp.Table):
|
|
396
|
+
seen.add(_table_qualifier(table))
|
|
397
|
+
return seen
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _iter_joins(parsed: Any) -> list[exp.Join]:
|
|
401
|
+
joins: list[exp.Join] = []
|
|
402
|
+
select = parsed.find(exp.Select)
|
|
403
|
+
if not select:
|
|
404
|
+
return joins
|
|
405
|
+
for j in select.args.get("joins") or []:
|
|
406
|
+
if isinstance(j, exp.Join):
|
|
407
|
+
joins.append(j)
|
|
408
|
+
return joins
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _iter_column_refs(parsed: Any) -> list[exp.Column]:
|
|
412
|
+
return [c for c in parsed.find_all(exp.Column) if isinstance(c, exp.Column)]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _column_table_qualifier(
|
|
416
|
+
column: exp.Column,
|
|
417
|
+
alias_map: dict[str, str],
|
|
418
|
+
physical_by_qual: dict[str, tuple[str, str]],
|
|
419
|
+
) -> str | None:
|
|
420
|
+
table_ref = column.table
|
|
421
|
+
if table_ref is None:
|
|
422
|
+
return None
|
|
423
|
+
if isinstance(table_ref, exp.Identifier):
|
|
424
|
+
key = table_ref.name.lower()
|
|
425
|
+
elif isinstance(table_ref, str):
|
|
426
|
+
key = table_ref.lower()
|
|
427
|
+
else:
|
|
428
|
+
return None
|
|
429
|
+
if key not in alias_map:
|
|
430
|
+
return None
|
|
431
|
+
raw = alias_map[key]
|
|
432
|
+
# Normalize to a key present in physical_by_qual (case-insensitive match).
|
|
433
|
+
for q in physical_by_qual:
|
|
434
|
+
if q.lower() == raw.lower():
|
|
435
|
+
return q
|
|
436
|
+
return raw
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Graph visibility: YAML config, filtering, and MCP integration helpers."""
|
|
2
|
+
|
|
3
|
+
from pretensor.visibility.config import (
|
|
4
|
+
VisibilityConfig,
|
|
5
|
+
VisibilityProfileRule,
|
|
6
|
+
default_visibility_path,
|
|
7
|
+
load_visibility_config,
|
|
8
|
+
merge_profile_into_base,
|
|
9
|
+
)
|
|
10
|
+
from pretensor.visibility.filter import VisibilityFilter
|
|
11
|
+
from pretensor.visibility.runtime import (
|
|
12
|
+
load_visibility_filter_for_graph_dir,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"VisibilityConfig",
|
|
17
|
+
"VisibilityFilter",
|
|
18
|
+
"VisibilityProfileRule",
|
|
19
|
+
"default_visibility_path",
|
|
20
|
+
"load_visibility_config",
|
|
21
|
+
"load_visibility_filter_for_graph_dir",
|
|
22
|
+
"merge_profile_into_base",
|
|
23
|
+
]
|