mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
* feat(setup): drop redundant Snowflake schema prompt; fall back to free-text on listSchemas failure Snowflake setup previously asked for a single schema as free text, then ran a multiselect against the discovered schemas — two schema questions back-to-back, with the first being only a session bootstrap. The SDK's `schema` is optional, so the bootstrap step is unnecessary. - Remove the free-text Snowflake schema prompt; only pass `schema` to snowflake-sdk when one is configured. - When `listSchemas()` fails (e.g. role lacks SHOW SCHEMAS), prompt the user for a comma-separated list, persist it as `schema_names`, and use it as both the table-list filter and the multiselect default. Applies to every driver with a scope-discovery spec, not just Snowflake. - Update docs to lead with `schema_names`; keep `schema_name` as a documented single-schema shorthand. * fix(snowflake): keep introspecting when primary-key discovery is denied The PK query joins INFORMATION_SCHEMA.TABLE_CONSTRAINTS and INFORMATION_SCHEMA.KEY_COLUMN_USAGE, which require grants the connection role may not have. Previously a 'SQL compilation error: Object ANALYTICS.INFORMATION_SCHEMA.KEY_COLUMN_USAGE does not exist or not authorized' aborted the entire introspect — schemas, columns, and row counts were all discarded over a missing nice-to-have. Wrap the constraint query in try/catch, log a one-line warning per schema, and return an empty PK map. Columns end up with primaryKey=false; relationship inference still has FK and profiling to fall back on. * fix(scan): unblock relationship discovery on Snowflake Two adjacent bugs prevented the scan's relationship pipeline from producing any joins on a Snowflake warehouse: - relationship-profiling.ts fell through to a default `GROUP_CONCAT` branch for unknown drivers. Snowflake has no GROUP_CONCAT, so every per-table profile query failed with "Unknown function GROUP_CONCAT". Add an explicit Snowflake branch that uses LISTAGG with a literal '\x1f' delimiter (Snowflake requires the delimiter to be a constant, so CHR(31) is rejected). - description-generation.ts destructured `connector.sampleTable` and `connector.sampleColumn` into bare locals, losing the `this` binding when the class-method connectors (Snowflake, Postgres, MySQL) were invoked. Every sample call threw "Cannot read properties of undefined (reading 'assertConnection')" and degraded LLM descriptions to metadata-only prompts. Call the methods through the connector instead. Without these, even after the primary-key probe is allowed to fail softly, the scan ends up with 0 validated relationships and an empty `joins:` block in every shard YAML. * test(scan): cover table-ref helpers * feat(scan): plumb tableScope through live-database introspection port * feat(scan): apply tableScope during metadata fetch * feat(scan): enforce table scope at fetch boundary * feat(scan): pool Snowflake sessions and batch enrichment for faster ingest (#206) * feat(cli): add RSA key-pair auth option to Snowflake setup wizard Extends the interactive Snowflake setup flow with an authentication-method prompt (password vs RSA/JWT key-pair). The RSA branch collects a private-key path (env/file/absolute) and an optional passphrase; the resulting connection config records `authMethod: 'rsa'` with `privateKey` and `passphrase` instead of `password`. * feat(scan): pool Snowflake sessions * fix(scan): reuse structural snapshots and cleanup connectors * feat(scan): parallelize relationship profiling * feat(scan): batch table description generation * docs: document Snowflake ingest concurrency knobs * fix(scan): close Snowflake ingest perf verification gaps * fix(scan): keep batched description failure bounded * feat(scan): dispatch query-history probes by connection driver Extract historic-sql dialect resolution into a shared helper so the status-project readiness check and the local ingest factory agree on which connections enable query history and which probe to run. The status command now picks the postgres/snowflake/bigquery probe based on the connection's driver instead of always reporting against postgres, which previously caused snowflake connections with queryHistory.enabled to surface a misleading "driver is snowflake" failure. Also drops a noisy console.warn from Snowflake primary-key discovery — INFORMATION_SCHEMA.KEY_COLUMN_USAGE is commonly ungranted for read-only roles and the FK + profiling paths handle the empty PK map already. * fix(llm): allow StructuredOutput tool and raise maxTurns for generateObject The Claude Code agent SDK announces an internal pseudo-tool named StructuredOutput in the system/init message whenever outputFormat is set to { type: 'json_schema' }. The runtime's isolation check built its allowedToolIds set only from MCP tool ids and treated StructuredOutput as an unexpected host-injected tool, so every generateObject call threw "Claude Code runtime isolation failed: tools=StructuredOutput ..." and the table-descriptions and relationship-LLM-proposal enrichment stages recorded null output across the board. Whitelist StructuredOutput specifically in generateObject's allowedToolIds — the check also enforces missing_tools symmetry, so generateText and runAgentLoop, which do not see StructuredOutput, must not require it. generateObject also ran with maxTurns: 1, which the model intermittently breached when it emitted thinking text before the structured response. Raised to 5 to give the schema-bound call enough headroom without allowing unbounded loops. The existing tests now exercise the path with an init message that announces StructuredOutput so the regression cannot slip back in. * chore(scripts): add ktx-reset.sh project-cleanup helper Convenience script for repeatable ingest testing: takes a project directory and prunes everything except ktx.yaml and .ktx/secrets/, so the next ktx setup or ktx ingest run starts from a known-clean state.
340 lines
11 KiB
Python
340 lines
11 KiB
Python
"""Portable database introspection helpers for KTX daemon."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
TABLES_SQL = """
|
|
select
|
|
t.table_catalog,
|
|
t.table_schema,
|
|
t.table_name,
|
|
obj_description(c.oid) as table_comment
|
|
from information_schema.tables t
|
|
join pg_catalog.pg_namespace n
|
|
on n.nspname = t.table_schema
|
|
join pg_catalog.pg_class c
|
|
on c.relnamespace = n.oid
|
|
and c.relname = t.table_name
|
|
where t.table_schema = any(%s)
|
|
and t.table_type = 'BASE TABLE'
|
|
and (
|
|
%s::jsonb is null
|
|
or exists (
|
|
select 1
|
|
from jsonb_to_recordset(%s::jsonb) as scope(catalog text, db text, name text)
|
|
where (scope.catalog is null or scope.catalog = current_database())
|
|
and (scope.db is null or scope.db = t.table_schema)
|
|
and scope.name = t.table_name
|
|
)
|
|
)
|
|
order by t.table_schema, t.table_name
|
|
"""
|
|
|
|
COLUMNS_SQL = """
|
|
select
|
|
current_database() as table_catalog,
|
|
n.nspname as table_schema,
|
|
c.relname as table_name,
|
|
a.attname as column_name,
|
|
pg_catalog.format_type(a.atttypid, a.atttypmod) as formatted_type,
|
|
not a.attnotnull as is_nullable,
|
|
exists (
|
|
select 1
|
|
from pg_catalog.pg_index i
|
|
where i.indrelid = c.oid
|
|
and i.indisprimary
|
|
and a.attnum = any(i.indkey)
|
|
) as is_primary_key,
|
|
pg_catalog.col_description(c.oid, a.attnum) as column_comment
|
|
from pg_catalog.pg_attribute a
|
|
join pg_catalog.pg_class c
|
|
on c.oid = a.attrelid
|
|
join pg_catalog.pg_namespace n
|
|
on n.oid = c.relnamespace
|
|
where n.nspname = any(%s)
|
|
and c.relkind in ('r', 'p')
|
|
and a.attnum > 0
|
|
and not a.attisdropped
|
|
and (
|
|
%s::jsonb is null
|
|
or exists (
|
|
select 1
|
|
from jsonb_to_recordset(%s::jsonb) as scope(catalog text, db text, name text)
|
|
where (scope.catalog is null or scope.catalog = current_database())
|
|
and (scope.db is null or scope.db = n.nspname)
|
|
and scope.name = c.relname
|
|
)
|
|
)
|
|
order by n.nspname, c.relname, a.attnum
|
|
"""
|
|
|
|
FOREIGN_KEYS_SQL = """
|
|
select
|
|
current_database() as table_catalog,
|
|
source_constraint.table_schema,
|
|
source_constraint.table_name,
|
|
source_key.column_name as from_column,
|
|
target_key.table_name as to_table,
|
|
target_key.column_name as to_column,
|
|
source_constraint.constraint_name
|
|
from information_schema.table_constraints source_constraint
|
|
join information_schema.key_column_usage source_key
|
|
on source_key.constraint_catalog = source_constraint.constraint_catalog
|
|
and source_key.constraint_schema = source_constraint.constraint_schema
|
|
and source_key.constraint_name = source_constraint.constraint_name
|
|
join information_schema.referential_constraints ref_constraint
|
|
on ref_constraint.constraint_catalog = source_constraint.constraint_catalog
|
|
and ref_constraint.constraint_schema = source_constraint.constraint_schema
|
|
and ref_constraint.constraint_name = source_constraint.constraint_name
|
|
join information_schema.key_column_usage target_key
|
|
on target_key.constraint_catalog = ref_constraint.unique_constraint_catalog
|
|
and target_key.constraint_schema = ref_constraint.unique_constraint_schema
|
|
and target_key.constraint_name = ref_constraint.unique_constraint_name
|
|
and target_key.ordinal_position = source_key.position_in_unique_constraint
|
|
where source_constraint.constraint_type = 'FOREIGN KEY'
|
|
and source_constraint.table_schema = any(%s)
|
|
and (
|
|
%s::jsonb is null
|
|
or exists (
|
|
select 1
|
|
from jsonb_to_recordset(%s::jsonb) as scope(catalog text, db text, name text)
|
|
where (scope.catalog is null or scope.catalog = current_database())
|
|
and (scope.db is null or scope.db = source_constraint.table_schema)
|
|
and scope.name = source_constraint.table_name
|
|
)
|
|
)
|
|
order by source_constraint.table_schema, source_constraint.table_name, source_constraint.constraint_name, source_key.ordinal_position
|
|
"""
|
|
|
|
|
|
class LiveDatabaseColumn(BaseModel):
|
|
name: str
|
|
type: str
|
|
nullable: bool = True
|
|
primary_key: bool = False
|
|
comment: str | None = None
|
|
|
|
|
|
class LiveDatabaseForeignKey(BaseModel):
|
|
from_column: str
|
|
to_table: str
|
|
to_column: str
|
|
constraint_name: str | None = None
|
|
|
|
|
|
class LiveDatabaseTable(BaseModel):
|
|
catalog: str | None = None
|
|
db: str | None = None
|
|
name: str
|
|
comment: str | None = None
|
|
columns: list[LiveDatabaseColumn] = Field(default_factory=list)
|
|
foreign_keys: list[LiveDatabaseForeignKey] = Field(default_factory=list)
|
|
|
|
|
|
class LiveDatabaseTableScopeRef(BaseModel):
|
|
catalog: str | None = None
|
|
db: str | None = None
|
|
name: str
|
|
|
|
|
|
class DatabaseIntrospectionRequest(BaseModel):
|
|
connection_id: str
|
|
driver: str = "postgres"
|
|
url: str
|
|
schemas: list[str] = Field(default_factory=lambda: ["public"])
|
|
statement_timeout_ms: int = Field(default=30_000, ge=1)
|
|
connection_timeout_seconds: int = Field(default=5, ge=1)
|
|
table_scope: list[LiveDatabaseTableScopeRef] | None = None
|
|
|
|
@field_validator("schemas")
|
|
@classmethod
|
|
def _schemas_must_not_be_empty(cls, value: list[str]) -> list[str]:
|
|
if not value:
|
|
raise ValueError("database introspection requires at least one schema")
|
|
return value
|
|
|
|
|
|
class DatabaseIntrospectionResponse(BaseModel):
|
|
connection_id: str
|
|
extracted_at: str
|
|
metadata: dict[str, Any]
|
|
tables: list[LiveDatabaseTable]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DatabaseIntrospectionRows:
|
|
table_rows: Sequence[Mapping[str, Any]]
|
|
column_rows: Sequence[Mapping[str, Any]]
|
|
foreign_key_rows: Sequence[Mapping[str, Any]]
|
|
|
|
|
|
DatabaseRowsLoader = Callable[[DatabaseIntrospectionRequest], DatabaseIntrospectionRows]
|
|
NowProvider = Callable[[], str]
|
|
|
|
|
|
def _driver_name(driver: str) -> str:
|
|
return driver.strip().lower()
|
|
|
|
|
|
def _table_key(catalog: str | None, db: str | None, name: str) -> str:
|
|
return f"{catalog or ''}\u0000{db or ''}\u0000{name}"
|
|
|
|
|
|
def _optional_string(row: Mapping[str, Any], key: str) -> str | None:
|
|
value = row.get(key)
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def _required_string(row: Mapping[str, Any], key: str) -> str:
|
|
value = row.get(key)
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError(f"database introspection row is missing string field {key}")
|
|
return value
|
|
|
|
|
|
def _statement_timeout_config(statement_timeout_ms: int) -> tuple[str, tuple[str]]:
|
|
return (
|
|
"SELECT set_config('statement_timeout', %s, true)",
|
|
(f"{int(statement_timeout_ms)}ms",),
|
|
)
|
|
|
|
|
|
def _table_scope_json(
|
|
table_scope: Sequence[LiveDatabaseTableScopeRef] | None,
|
|
) -> str | None:
|
|
if table_scope is None:
|
|
return None
|
|
return json.dumps(
|
|
[
|
|
{
|
|
"catalog": ref.catalog,
|
|
"db": ref.db,
|
|
"name": ref.name,
|
|
}
|
|
for ref in table_scope
|
|
]
|
|
)
|
|
|
|
|
|
def _load_postgres_rows(
|
|
request: DatabaseIntrospectionRequest,
|
|
) -> DatabaseIntrospectionRows:
|
|
try:
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
except ImportError as error:
|
|
raise RuntimeError(
|
|
"psycopg is required for Postgres database introspection"
|
|
) from error
|
|
|
|
connection = psycopg.connect(
|
|
request.url,
|
|
connect_timeout=request.connection_timeout_seconds,
|
|
application_name="ktx-daemon-database-introspection",
|
|
row_factory=dict_row,
|
|
)
|
|
try:
|
|
connection.execute("BEGIN READ ONLY")
|
|
try:
|
|
connection.execute(*_statement_timeout_config(request.statement_timeout_ms))
|
|
scope_json = _table_scope_json(request.table_scope)
|
|
params = (request.schemas, scope_json, scope_json)
|
|
table_rows = list(connection.execute(TABLES_SQL, params))
|
|
column_rows = list(connection.execute(COLUMNS_SQL, params))
|
|
foreign_key_rows = list(connection.execute(FOREIGN_KEYS_SQL, params))
|
|
connection.execute("COMMIT")
|
|
except Exception:
|
|
connection.execute("ROLLBACK")
|
|
raise
|
|
finally:
|
|
connection.close()
|
|
|
|
return DatabaseIntrospectionRows(
|
|
table_rows=table_rows,
|
|
column_rows=column_rows,
|
|
foreign_key_rows=foreign_key_rows,
|
|
)
|
|
|
|
|
|
def _map_rows_to_tables(rows: DatabaseIntrospectionRows) -> list[LiveDatabaseTable]:
|
|
tables: dict[str, LiveDatabaseTable] = {}
|
|
|
|
for row in rows.table_rows:
|
|
catalog = _optional_string(row, "table_catalog")
|
|
db = _required_string(row, "table_schema")
|
|
name = _required_string(row, "table_name")
|
|
key = _table_key(catalog, db, name)
|
|
tables[key] = LiveDatabaseTable(
|
|
catalog=catalog,
|
|
db=db,
|
|
name=name,
|
|
comment=_optional_string(row, "table_comment"),
|
|
)
|
|
|
|
for row in rows.column_rows:
|
|
catalog = _optional_string(row, "table_catalog")
|
|
db = _required_string(row, "table_schema")
|
|
table_name = _required_string(row, "table_name")
|
|
table = tables.get(_table_key(catalog, db, table_name))
|
|
if table is None:
|
|
continue
|
|
|
|
table.columns.append(
|
|
LiveDatabaseColumn(
|
|
name=_required_string(row, "column_name"),
|
|
type=_required_string(row, "formatted_type"),
|
|
nullable=bool(row.get("is_nullable")),
|
|
primary_key=bool(row.get("is_primary_key")),
|
|
comment=_optional_string(row, "column_comment"),
|
|
)
|
|
)
|
|
|
|
for row in rows.foreign_key_rows:
|
|
catalog = _optional_string(row, "table_catalog")
|
|
db = _required_string(row, "table_schema")
|
|
table_name = _required_string(row, "table_name")
|
|
table = tables.get(_table_key(catalog, db, table_name))
|
|
if table is None:
|
|
continue
|
|
|
|
table.foreign_keys.append(
|
|
LiveDatabaseForeignKey(
|
|
from_column=_required_string(row, "from_column"),
|
|
to_table=_required_string(row, "to_table"),
|
|
to_column=_required_string(row, "to_column"),
|
|
constraint_name=_optional_string(row, "constraint_name"),
|
|
)
|
|
)
|
|
|
|
return sorted(
|
|
tables.values(),
|
|
key=lambda table: _table_key(table.catalog, table.db, table.name),
|
|
)
|
|
|
|
|
|
def introspect_database_response(
|
|
request: DatabaseIntrospectionRequest,
|
|
*,
|
|
load_rows: DatabaseRowsLoader | None = None,
|
|
now: NowProvider | None = None,
|
|
) -> DatabaseIntrospectionResponse:
|
|
driver = _driver_name(request.driver)
|
|
if driver not in {"postgres", "postgresql"}:
|
|
raise ValueError('database introspection supports only driver "postgres"')
|
|
|
|
rows = (load_rows or _load_postgres_rows)(request)
|
|
timestamp = now() if now else datetime.now(timezone.utc).isoformat()
|
|
return DatabaseIntrospectionResponse(
|
|
connection_id=request.connection_id,
|
|
extracted_at=timestamp,
|
|
metadata={"driver": driver, "schemas": list(request.schemas)},
|
|
tables=_map_rows_to_tables(rows),
|
|
)
|