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
pretensor/core/ids.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Stable identifiers for graph nodes and explicit FK edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
from pretensor.connectors.models import ForeignKey, ViewDependency
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"table_node_id",
|
|
11
|
+
"column_node_id",
|
|
12
|
+
"fk_edge_id",
|
|
13
|
+
"lineage_edge_id",
|
|
14
|
+
"dbt_lineage_edge_id",
|
|
15
|
+
"entity_node_id",
|
|
16
|
+
"metric_template_node_id",
|
|
17
|
+
"external_consumer_node_id",
|
|
18
|
+
"consumes_edge_id",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def table_node_id(connection_name: str, schema_name: str, table_name: str) -> str:
|
|
23
|
+
"""Stable node id for a physical table within one indexed connection."""
|
|
24
|
+
return f"{connection_name}::{schema_name}::{table_name}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def column_node_id(
|
|
28
|
+
connection_name: str, schema_name: str, table_name: str, column_name: str
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Stable node id for a physical column."""
|
|
31
|
+
return f"{connection_name}::{schema_name}::{table_name}::{column_name}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def entity_node_id(connection_name: str, entity_name: str) -> str:
|
|
35
|
+
"""Stable node id for a business entity within one indexed connection."""
|
|
36
|
+
return f"{connection_name}::entity::{entity_name}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def metric_template_node_id(
|
|
40
|
+
connection_name: str, database: str, template_name: str
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Stable node id for a metric template scoped to one logical database."""
|
|
43
|
+
return f"{connection_name}::{database}::metric::{template_name}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fk_edge_id(fk: ForeignKey) -> str:
|
|
47
|
+
"""Stable edge id from explicit FK column pair (per source table)."""
|
|
48
|
+
return (
|
|
49
|
+
f"{fk.source_schema}.{fk.source_table}.{fk.source_column}"
|
|
50
|
+
f"->{fk.target_schema}.{fk.target_table}.{fk.target_column}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def lineage_edge_id(connection_name: str, dep: ViewDependency) -> str:
|
|
55
|
+
"""Stable id for a LINEAGE edge (dedupes re-index and multiple evidence paths)."""
|
|
56
|
+
raw = (
|
|
57
|
+
f"{connection_name}|{dep.source_schema}.{dep.source_table}"
|
|
58
|
+
f"->{dep.target_schema}.{dep.target_table}|{dep.lineage_type}"
|
|
59
|
+
)
|
|
60
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
|
|
61
|
+
return f"lineage::{digest}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def dbt_lineage_edge_id(
|
|
65
|
+
connection_name: str, parent_dbt_id: str, child_dbt_id: str
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Stable id for a dbt ``parent_map`` LINEAGE edge (idempotent re-runs)."""
|
|
68
|
+
raw = f"{connection_name}|dbt|{parent_dbt_id}->{child_dbt_id}|model_dependency"
|
|
69
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
|
|
70
|
+
return f"lineage::dbt::{digest}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def external_consumer_node_id(
|
|
74
|
+
connection_name: str,
|
|
75
|
+
service_name: str,
|
|
76
|
+
file_path: str,
|
|
77
|
+
line_start: int,
|
|
78
|
+
sql_fingerprint: str,
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Stable node id for one code location that issues a specific SQL statement.
|
|
81
|
+
|
|
82
|
+
Keyed by ``(connection, service, repo-relative file, line, normalized SQL
|
|
83
|
+
fingerprint)`` so the same statement in the same place dedupes across
|
|
84
|
+
re-scans, while the same statement at two call sites — or resolved against
|
|
85
|
+
two different connections — gets its own node. Line drift between scans
|
|
86
|
+
produces a fresh id; the stale-consumer sweep reclaims the old one.
|
|
87
|
+
"""
|
|
88
|
+
raw = f"{connection_name}|{service_name}|{file_path}|{line_start}|{sql_fingerprint}"
|
|
89
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
|
|
90
|
+
return f"consumer::{digest}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def consumes_edge_id(consumer_node_id: str, table_node_id: str, op: str) -> str:
|
|
94
|
+
"""Stable id for a CONSUMES edge (idempotent by consumer, table, and op).
|
|
95
|
+
|
|
96
|
+
``op`` is part of the key so a statement that both reads and writes the same
|
|
97
|
+
table yields two distinct edges rather than clobbering one.
|
|
98
|
+
"""
|
|
99
|
+
raw = f"{consumer_node_id}->{table_node_id}|{op}"
|
|
100
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
|
|
101
|
+
return f"consumes::{digest}"
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Portable JSON export helpers for Kuzu graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypeAlias, TypedDict
|
|
8
|
+
|
|
9
|
+
from pretensor.core.store import KuzuStore
|
|
10
|
+
|
|
11
|
+
JsonPrimitive: TypeAlias = str | int | float | bool | None
|
|
12
|
+
JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
|
|
13
|
+
EdgeRow: TypeAlias = dict[str, JsonValue]
|
|
14
|
+
|
|
15
|
+
_EDGE_ENDPOINT_COLUMNS = frozenset({"from", "to", "src", "dst"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NodeTypeExport(TypedDict):
|
|
19
|
+
"""Portable representation of one node label."""
|
|
20
|
+
|
|
21
|
+
type: str
|
|
22
|
+
properties: list[str]
|
|
23
|
+
rows: list[dict[str, JsonValue]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EdgeTypeExport(TypedDict):
|
|
27
|
+
"""Portable representation of one edge label."""
|
|
28
|
+
|
|
29
|
+
type: str
|
|
30
|
+
properties: list[str]
|
|
31
|
+
rows: list[EdgeRow]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PortableGraphExport(TypedDict):
|
|
35
|
+
"""Top-level export payload shape."""
|
|
36
|
+
|
|
37
|
+
connection_name: str
|
|
38
|
+
database: str
|
|
39
|
+
graph_path: str
|
|
40
|
+
exported_at: str
|
|
41
|
+
node_types: list[NodeTypeExport]
|
|
42
|
+
edge_types: list[EdgeTypeExport]
|
|
43
|
+
stats: dict[str, int]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _NodeExportResult(TypedDict):
|
|
47
|
+
payload: NodeTypeExport
|
|
48
|
+
node_ids: set[str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def export_graph_payload(
|
|
52
|
+
store: KuzuStore,
|
|
53
|
+
*,
|
|
54
|
+
connection_name: str,
|
|
55
|
+
database_name: str,
|
|
56
|
+
graph_path: Path,
|
|
57
|
+
) -> PortableGraphExport:
|
|
58
|
+
"""Materialize a portable JSON payload for all node and edge labels.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
store: Open graph store.
|
|
62
|
+
connection_name: Logical registry connection name being exported.
|
|
63
|
+
database_name: Database key from the registry entry.
|
|
64
|
+
graph_path: Path to the Kuzu graph file.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Portable export payload containing all label rows and properties.
|
|
68
|
+
"""
|
|
69
|
+
rows = store.query_all_rows("CALL show_tables() RETURN name, type ORDER BY name")
|
|
70
|
+
node_types: list[NodeTypeExport] = []
|
|
71
|
+
edge_types: list[EdgeTypeExport] = []
|
|
72
|
+
included_node_ids: set[str] = set()
|
|
73
|
+
node_count = 0
|
|
74
|
+
edge_count = 0
|
|
75
|
+
node_table_names: list[str] = []
|
|
76
|
+
edge_table_names: list[str] = []
|
|
77
|
+
for table_name_raw, table_type_raw in rows:
|
|
78
|
+
table_name = str(table_name_raw)
|
|
79
|
+
table_type = str(table_type_raw).upper()
|
|
80
|
+
if "REL" in table_type:
|
|
81
|
+
edge_table_names.append(table_name)
|
|
82
|
+
else:
|
|
83
|
+
node_table_names.append(table_name)
|
|
84
|
+
|
|
85
|
+
for table_name in node_table_names:
|
|
86
|
+
columns = _table_columns(store, table_name)
|
|
87
|
+
node_result = _export_node_type(
|
|
88
|
+
store,
|
|
89
|
+
table_name=table_name,
|
|
90
|
+
columns=columns,
|
|
91
|
+
connection_name=connection_name,
|
|
92
|
+
database_name=database_name,
|
|
93
|
+
)
|
|
94
|
+
node_types.append(node_result["payload"])
|
|
95
|
+
included_node_ids.update(node_result["node_ids"])
|
|
96
|
+
node_count += len(node_result["payload"]["rows"])
|
|
97
|
+
|
|
98
|
+
for table_name in edge_table_names:
|
|
99
|
+
columns = _table_columns(store, table_name)
|
|
100
|
+
edge_payload = _export_edge_type(
|
|
101
|
+
store,
|
|
102
|
+
table_name=table_name,
|
|
103
|
+
columns=columns,
|
|
104
|
+
included_node_ids=included_node_ids,
|
|
105
|
+
)
|
|
106
|
+
edge_types.append(edge_payload)
|
|
107
|
+
edge_count += len(edge_payload["rows"])
|
|
108
|
+
return {
|
|
109
|
+
"connection_name": connection_name,
|
|
110
|
+
"database": database_name,
|
|
111
|
+
"graph_path": str(graph_path),
|
|
112
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
113
|
+
"node_types": node_types,
|
|
114
|
+
"edge_types": edge_types,
|
|
115
|
+
"stats": {
|
|
116
|
+
"node_types": len(node_types),
|
|
117
|
+
"edge_types": len(edge_types),
|
|
118
|
+
"node_count": node_count,
|
|
119
|
+
"edge_count": edge_count,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _table_columns(store: KuzuStore, table_name: str) -> list[str]:
|
|
125
|
+
quoted = table_name.replace("'", "''")
|
|
126
|
+
rows = store.query_all_rows(f"CALL table_info('{quoted}') RETURN name, type")
|
|
127
|
+
return [str(row[0]) for row in rows if row and row[0] is not None]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _export_node_type(
|
|
131
|
+
store: KuzuStore,
|
|
132
|
+
*,
|
|
133
|
+
table_name: str,
|
|
134
|
+
columns: list[str],
|
|
135
|
+
connection_name: str,
|
|
136
|
+
database_name: str,
|
|
137
|
+
) -> _NodeExportResult:
|
|
138
|
+
projections = ", ".join(
|
|
139
|
+
f"n.{_quote_identifier(column)} AS {_quote_identifier(column)}"
|
|
140
|
+
for column in columns
|
|
141
|
+
)
|
|
142
|
+
order_clause = (
|
|
143
|
+
f" ORDER BY n.{_quote_identifier('node_id')}" if "node_id" in columns else ""
|
|
144
|
+
)
|
|
145
|
+
scope = _scope_predicate(columns, "n")
|
|
146
|
+
if scope is None:
|
|
147
|
+
where_clause = ""
|
|
148
|
+
query_params: dict[str, str] = {}
|
|
149
|
+
else:
|
|
150
|
+
scope_predicate, param_name = scope
|
|
151
|
+
where_clause = f" WHERE {scope_predicate}"
|
|
152
|
+
query_params = {
|
|
153
|
+
param_name: connection_name
|
|
154
|
+
if param_name == "connection_name"
|
|
155
|
+
else database_name
|
|
156
|
+
}
|
|
157
|
+
cypher = (
|
|
158
|
+
f"MATCH (n:{_quote_identifier(table_name)})"
|
|
159
|
+
f"{where_clause} "
|
|
160
|
+
f"RETURN {projections}{order_clause}"
|
|
161
|
+
)
|
|
162
|
+
rows = store.query_all_rows(cypher, query_params)
|
|
163
|
+
materialized_rows = [_materialize_row(columns, row) for row in rows]
|
|
164
|
+
node_ids = {
|
|
165
|
+
str(row_map["node_id"])
|
|
166
|
+
for row_map in materialized_rows
|
|
167
|
+
if "node_id" in row_map and row_map["node_id"] is not None
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
"payload": {
|
|
171
|
+
"type": table_name,
|
|
172
|
+
"properties": columns,
|
|
173
|
+
"rows": materialized_rows,
|
|
174
|
+
},
|
|
175
|
+
"node_ids": node_ids,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _export_edge_type(
|
|
180
|
+
store: KuzuStore,
|
|
181
|
+
*,
|
|
182
|
+
table_name: str,
|
|
183
|
+
columns: list[str],
|
|
184
|
+
included_node_ids: set[str],
|
|
185
|
+
) -> EdgeTypeExport:
|
|
186
|
+
property_columns = [
|
|
187
|
+
column for column in columns if column.lower() not in _EDGE_ENDPOINT_COLUMNS
|
|
188
|
+
]
|
|
189
|
+
projections = [
|
|
190
|
+
f"src.{_quote_identifier('node_id')} AS {_quote_identifier('__from_node_id')}",
|
|
191
|
+
f"dst.{_quote_identifier('node_id')} AS {_quote_identifier('__to_node_id')}",
|
|
192
|
+
]
|
|
193
|
+
projections.extend(
|
|
194
|
+
f"r.{_quote_identifier(column)} AS {_quote_identifier(column)}"
|
|
195
|
+
for column in property_columns
|
|
196
|
+
)
|
|
197
|
+
field_names = ["__from_node_id", "__to_node_id", *property_columns]
|
|
198
|
+
cypher = (
|
|
199
|
+
f"MATCH (src)-[r:{_quote_identifier(table_name)}]->(dst) "
|
|
200
|
+
f"RETURN {', '.join(projections)} "
|
|
201
|
+
f"ORDER BY {_quote_identifier('__from_node_id')}, {_quote_identifier('__to_node_id')}"
|
|
202
|
+
)
|
|
203
|
+
rows = store.query_all_rows(cypher)
|
|
204
|
+
filtered_rows = [
|
|
205
|
+
row
|
|
206
|
+
for row in rows
|
|
207
|
+
if str(row[0]) in included_node_ids and str(row[1]) in included_node_ids
|
|
208
|
+
]
|
|
209
|
+
return {
|
|
210
|
+
"type": table_name,
|
|
211
|
+
"properties": property_columns,
|
|
212
|
+
"rows": [_materialize_edge_row(field_names, row) for row in filtered_rows],
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _scope_predicate(columns: list[str], alias: str) -> tuple[str, str] | None:
|
|
217
|
+
"""Return (predicate, query param name) for the first matching scope column.
|
|
218
|
+
|
|
219
|
+
Only one of ``connection_name``/``database_name`` is ever bound per query,
|
|
220
|
+
so the predicate text and the params dict built from it must stay in sync
|
|
221
|
+
(Kuzu prepared statements reject params the query text never references).
|
|
222
|
+
"""
|
|
223
|
+
if "connection_name" in columns:
|
|
224
|
+
return (
|
|
225
|
+
f"{alias}.{_quote_identifier('connection_name')} = $connection_name",
|
|
226
|
+
"connection_name",
|
|
227
|
+
)
|
|
228
|
+
if "database" in columns:
|
|
229
|
+
return (
|
|
230
|
+
f"{alias}.{_quote_identifier('database')} = $database_name",
|
|
231
|
+
"database_name",
|
|
232
|
+
)
|
|
233
|
+
if "database_key" in columns:
|
|
234
|
+
return (
|
|
235
|
+
f"{alias}.{_quote_identifier('database_key')} = $database_name",
|
|
236
|
+
"database_name",
|
|
237
|
+
)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _materialize_row(
|
|
242
|
+
field_names: list[str], row: tuple[object, ...]
|
|
243
|
+
) -> dict[str, JsonValue]:
|
|
244
|
+
return {
|
|
245
|
+
field_names[idx]: _to_json_value(value)
|
|
246
|
+
for idx, value in enumerate(row[: len(field_names)])
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _materialize_edge_row(field_names: list[str], row: tuple[object, ...]) -> EdgeRow:
|
|
251
|
+
row_map = _materialize_row(field_names, row)
|
|
252
|
+
return {
|
|
253
|
+
"__from_node_id": row_map["__from_node_id"],
|
|
254
|
+
"__to_node_id": row_map["__to_node_id"],
|
|
255
|
+
**{
|
|
256
|
+
key: value
|
|
257
|
+
for key, value in row_map.items()
|
|
258
|
+
if key not in {"__from_node_id", "__to_node_id"}
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _to_json_value(value: object) -> JsonValue:
|
|
264
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
265
|
+
return value
|
|
266
|
+
if isinstance(value, (datetime, date)):
|
|
267
|
+
return value.isoformat()
|
|
268
|
+
if isinstance(value, (list, tuple)):
|
|
269
|
+
return [_to_json_value(item) for item in value]
|
|
270
|
+
if isinstance(value, dict):
|
|
271
|
+
return {str(key): _to_json_value(item) for key, item in value.items()}
|
|
272
|
+
return str(value)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _quote_identifier(identifier: str) -> str:
|
|
276
|
+
return "`" + identifier.replace("`", "``") + "`"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Raw Cypher execution wrapper extracted from KuzuStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import kuzu
|
|
10
|
+
|
|
11
|
+
from pretensor.observability import log_timed_operation
|
|
12
|
+
|
|
13
|
+
# Use the same logger name as store.py so existing log pipelines see identical
|
|
14
|
+
# source fields regardless of which layer emits the record.
|
|
15
|
+
logger = logging.getLogger("pretensor.core.store")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueryRunner:
|
|
19
|
+
"""Thin wrapper around a kuzu.Connection that handles all Cypher execution.
|
|
20
|
+
|
|
21
|
+
Owns the three execution methods that were previously on KuzuStore:
|
|
22
|
+
``execute``, ``execute_write``, and ``query_all_rows``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, conn: kuzu.Connection, path: Path) -> None:
|
|
26
|
+
self._conn = conn
|
|
27
|
+
# Stored so execute_write can include graph_path in the log record,
|
|
28
|
+
# matching the field emitted by the original KuzuStore.execute_write.
|
|
29
|
+
self._path = path
|
|
30
|
+
|
|
31
|
+
def execute_write(self, query: str, params: dict[str, Any] | None = None) -> None:
|
|
32
|
+
"""Execute a write (mutation) Cypher query. Use this for DELETE / SET operations."""
|
|
33
|
+
with log_timed_operation(
|
|
34
|
+
logger,
|
|
35
|
+
event="graph.execute_write",
|
|
36
|
+
level=logging.DEBUG,
|
|
37
|
+
has_params=bool(params),
|
|
38
|
+
query_preview=query.strip().splitlines()[0][:120] if query.strip() else "",
|
|
39
|
+
graph_path=str(self._path),
|
|
40
|
+
):
|
|
41
|
+
if params:
|
|
42
|
+
self._conn.execute(query, params)
|
|
43
|
+
else:
|
|
44
|
+
self._conn.execute(query)
|
|
45
|
+
|
|
46
|
+
def execute(
|
|
47
|
+
self, cypher: str, parameters: dict[str, Any] | None = None
|
|
48
|
+
) -> kuzu.QueryResult | list[kuzu.QueryResult]:
|
|
49
|
+
"""Run a read query (or arbitrary Cypher) with optional parameters."""
|
|
50
|
+
return self._conn.execute(cypher, parameters)
|
|
51
|
+
|
|
52
|
+
def query_all_rows(
|
|
53
|
+
self, cypher: str, parameters: dict[str, Any] | None = None
|
|
54
|
+
) -> list[tuple[Any, ...]]:
|
|
55
|
+
"""Execute Cypher and materialize all result rows as tuples.
|
|
56
|
+
|
|
57
|
+
Keeps callers (MCP, search index) off raw ``kuzu.QueryResult`` handles
|
|
58
|
+
while still routing reads through :class:`QueryRunner`.
|
|
59
|
+
"""
|
|
60
|
+
result = self.execute(cypher, parameters)
|
|
61
|
+
if isinstance(result, list):
|
|
62
|
+
raise TypeError("Expected a single QueryResult from query_all_rows")
|
|
63
|
+
rows: list[tuple[Any, ...]] = []
|
|
64
|
+
while result.has_next():
|
|
65
|
+
nxt = result.get_next()
|
|
66
|
+
rows.append(tuple(nxt) if not isinstance(nxt, tuple) else nxt)
|
|
67
|
+
return rows
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""JSON registry of indexed databases under ``.pretensor/``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from pretensor.core.dsn_crypto import DSNEncryptor
|
|
13
|
+
from pretensor.core.secure_io import atomic_write_text
|
|
14
|
+
from pretensor.errors import PretensorError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"GraphRegistry",
|
|
18
|
+
"MultiDatabaseRegistry",
|
|
19
|
+
"RegistryEntry",
|
|
20
|
+
"DatabaseNotFoundError",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_REGISTRY_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatabaseNotFoundError(PretensorError, KeyError):
|
|
27
|
+
"""Raised when a connection name is missing from the registry."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RegistryEntry(BaseModel):
|
|
31
|
+
"""One indexed connection."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
|
|
35
|
+
connection_name: str
|
|
36
|
+
database: str
|
|
37
|
+
dsn: str = ""
|
|
38
|
+
dsn_encrypted: str | None = None
|
|
39
|
+
graph_path: str
|
|
40
|
+
unified_graph_path: str | None = None
|
|
41
|
+
last_indexed_at: datetime
|
|
42
|
+
# Persisted by index/add so reindex/detect_changes can parse stored DSNs.
|
|
43
|
+
dialect: Literal["postgres", "mysql", "snowflake", "bigquery"] = "postgres"
|
|
44
|
+
table_count: int | None = None
|
|
45
|
+
description: str | None = None
|
|
46
|
+
# Capability provenance for ``list_databases``: distinguishes "user didn't
|
|
47
|
+
# run this" ("not_attempted") from "ran but found nothing" ("empty").
|
|
48
|
+
dbt_manifest_path: str | None = None
|
|
49
|
+
llm_enrichment_ran: bool = False
|
|
50
|
+
|
|
51
|
+
def plaintext_dsn(self, encryptor: DSNEncryptor | None) -> str:
|
|
52
|
+
"""Return the connection URL, decrypting when ``dsn_encrypted`` is set."""
|
|
53
|
+
if self.dsn_encrypted:
|
|
54
|
+
if encryptor is None:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"DSNEncryptor required for encrypted registry entries"
|
|
57
|
+
)
|
|
58
|
+
return encryptor.decrypt(self.dsn_encrypted)
|
|
59
|
+
return self.dsn
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _RegistryFile(BaseModel):
|
|
63
|
+
version: int = _REGISTRY_VERSION
|
|
64
|
+
entries: dict[str, RegistryEntry] = Field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GraphRegistry:
|
|
68
|
+
"""Load and save ``registry.json`` tracking graph files per connection."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, registry_path: Path) -> None:
|
|
71
|
+
self._path = registry_path
|
|
72
|
+
self._data = _RegistryFile()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def path(self) -> Path:
|
|
76
|
+
return self._path
|
|
77
|
+
|
|
78
|
+
def load(self) -> GraphRegistry:
|
|
79
|
+
"""Load from disk if the file exists; otherwise start empty."""
|
|
80
|
+
if not self._path.exists():
|
|
81
|
+
return self
|
|
82
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
83
|
+
if isinstance(raw, dict) and "entries" not in raw and "version" not in raw:
|
|
84
|
+
raw = {"version": _REGISTRY_VERSION, "entries": raw}
|
|
85
|
+
self._data = _RegistryFile.model_validate(raw)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def get(self, connection_name: str) -> RegistryEntry | None:
|
|
89
|
+
return self._data.entries.get(connection_name)
|
|
90
|
+
|
|
91
|
+
def require(self, connection_name: str) -> RegistryEntry:
|
|
92
|
+
"""Return an entry or raise :class:`DatabaseNotFoundError`."""
|
|
93
|
+
entry = self.get(connection_name)
|
|
94
|
+
if entry is None:
|
|
95
|
+
raise DatabaseNotFoundError(
|
|
96
|
+
f"No registry entry for connection {connection_name!r}. "
|
|
97
|
+
"Use `pretensor list` or `pretensor add`."
|
|
98
|
+
)
|
|
99
|
+
return entry
|
|
100
|
+
|
|
101
|
+
def list_entries(self) -> list[RegistryEntry]:
|
|
102
|
+
"""Return all entries sorted by connection name."""
|
|
103
|
+
return sorted(self._data.entries.values(), key=lambda e: e.connection_name)
|
|
104
|
+
|
|
105
|
+
def remove(self, connection_name: str) -> None:
|
|
106
|
+
"""Drop a connection from the in-memory registry (call :meth:`save`)."""
|
|
107
|
+
entries = dict(self._data.entries)
|
|
108
|
+
entries.pop(connection_name, None)
|
|
109
|
+
self._data = _RegistryFile(version=self._data.version, entries=entries)
|
|
110
|
+
|
|
111
|
+
def upsert(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
connection_name: str,
|
|
115
|
+
database: str,
|
|
116
|
+
dsn: str,
|
|
117
|
+
graph_path: Path,
|
|
118
|
+
indexed_at: datetime | None = None,
|
|
119
|
+
encrypt_dsn: bool = False,
|
|
120
|
+
encryptor: DSNEncryptor | None = None,
|
|
121
|
+
unified_graph_path: Path | None = None,
|
|
122
|
+
dialect: Literal["postgres", "mysql", "snowflake", "bigquery"] = "postgres",
|
|
123
|
+
table_count: int | None = None,
|
|
124
|
+
description: str | None = None,
|
|
125
|
+
dbt_manifest_path: str | None = None,
|
|
126
|
+
llm_enrichment_ran: bool = False,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Record or replace an entry for ``connection_name``."""
|
|
129
|
+
when = indexed_at or datetime.now(timezone.utc)
|
|
130
|
+
dsn_plain = ""
|
|
131
|
+
dsn_enc: str | None = None
|
|
132
|
+
if encrypt_dsn:
|
|
133
|
+
if encryptor is None:
|
|
134
|
+
raise ValueError("encryptor is required when encrypt_dsn is True")
|
|
135
|
+
dsn_enc = encryptor.encrypt(dsn)
|
|
136
|
+
else:
|
|
137
|
+
dsn_plain = dsn
|
|
138
|
+
uni = str(unified_graph_path.resolve()) if unified_graph_path else None
|
|
139
|
+
entry = RegistryEntry(
|
|
140
|
+
connection_name=connection_name,
|
|
141
|
+
database=database,
|
|
142
|
+
dsn=dsn_plain,
|
|
143
|
+
dsn_encrypted=dsn_enc,
|
|
144
|
+
graph_path=str(graph_path.resolve()),
|
|
145
|
+
unified_graph_path=uni,
|
|
146
|
+
last_indexed_at=when,
|
|
147
|
+
dialect=dialect,
|
|
148
|
+
table_count=table_count,
|
|
149
|
+
description=description,
|
|
150
|
+
dbt_manifest_path=dbt_manifest_path,
|
|
151
|
+
llm_enrichment_ran=llm_enrichment_ran,
|
|
152
|
+
)
|
|
153
|
+
entries = dict(self._data.entries)
|
|
154
|
+
entries[connection_name] = entry
|
|
155
|
+
self._data = _RegistryFile(version=_REGISTRY_VERSION, entries=entries)
|
|
156
|
+
|
|
157
|
+
def update_indexed_metadata(
|
|
158
|
+
self,
|
|
159
|
+
connection_name: str,
|
|
160
|
+
*,
|
|
161
|
+
table_count: int,
|
|
162
|
+
indexed_at: datetime | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Refresh ``table_count`` and optional timestamp after indexing."""
|
|
165
|
+
cur = self.require(connection_name)
|
|
166
|
+
when = indexed_at or datetime.now(timezone.utc)
|
|
167
|
+
entries = dict(self._data.entries)
|
|
168
|
+
entries[connection_name] = RegistryEntry(
|
|
169
|
+
connection_name=cur.connection_name,
|
|
170
|
+
database=cur.database,
|
|
171
|
+
dsn=cur.dsn,
|
|
172
|
+
dsn_encrypted=cur.dsn_encrypted,
|
|
173
|
+
graph_path=cur.graph_path,
|
|
174
|
+
unified_graph_path=cur.unified_graph_path,
|
|
175
|
+
last_indexed_at=when,
|
|
176
|
+
dialect=cur.dialect,
|
|
177
|
+
table_count=table_count,
|
|
178
|
+
description=cur.description,
|
|
179
|
+
dbt_manifest_path=cur.dbt_manifest_path,
|
|
180
|
+
llm_enrichment_ran=cur.llm_enrichment_ran,
|
|
181
|
+
)
|
|
182
|
+
self._data = _RegistryFile(version=self._data.version, entries=entries)
|
|
183
|
+
|
|
184
|
+
def unified_graph_file(self) -> Path | None:
|
|
185
|
+
"""Return the shared Kuzu path when all entries agree, else ``None``."""
|
|
186
|
+
entries = self.list_entries()
|
|
187
|
+
if not entries:
|
|
188
|
+
return None
|
|
189
|
+
paths = {e.unified_graph_path or e.graph_path for e in entries}
|
|
190
|
+
if len(paths) != 1:
|
|
191
|
+
return None
|
|
192
|
+
return Path(next(iter(paths)))
|
|
193
|
+
|
|
194
|
+
def save(self) -> None:
|
|
195
|
+
"""Write the registry atomically with owner-only (0o600) permissions.
|
|
196
|
+
|
|
197
|
+
The registry can hold a cleartext DSN for entries that predate
|
|
198
|
+
default-on encryption, so it is written 0o600 to keep credentials off
|
|
199
|
+
a shared host's other users.
|
|
200
|
+
"""
|
|
201
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
payload: dict[str, Any] = self._data.model_dump(mode="json")
|
|
203
|
+
atomic_write_text(self._path, json.dumps(payload, indent=2), mode=0o600)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class MultiDatabaseRegistry(GraphRegistry):
|
|
207
|
+
"""Alias for the multi-connection registry (Phase 3 unified graph)."""
|
|
208
|
+
|
|
209
|
+
pass
|