Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/schema-hierarchy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Schema hierarchy integration

on:
pull_request:
paths:
- 'sqlit/**'
- 'tests/integration/schema_hierarchy/**'
- 'tools/run_schema_provider_integration.py'
- '.github/workflows/schema-hierarchy.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
providers:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- name: Install integration dependencies
run: uv sync --group test --extra postgres --extra mssql --extra snowflake
- name: Run all supported providers and registry fallback contracts
run: uv run --no-sync python tools/run_schema_provider_integration.py --output /tmp/schema-hierarchy-evidence
- name: Keep test reports and actual app captures
if: always()
uses: actions/upload-artifact@v4
with:
name: schema-hierarchy-evidence
path: /tmp/schema-hierarchy-evidence
if-no-files-found: error
1 change: 1 addition & 0 deletions sqlit/domains/connections/app/mock_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def _display_info(config: ConnectionConfig) -> str:
default_schema=str(getattr(adapter, "default_schema", "")),
system_databases=frozenset(getattr(adapter, "system_databases", frozenset())),
supports_foreign_keys=bool(getattr(adapter, "supports_foreign_keys", False)),
supports_schema_grouping=bool(getattr(adapter, "supports_schema_grouping", False)),
)

def apply_database_override(config: ConnectionConfig, database: str | None) -> ConnectionConfig:
Expand Down
1 change: 1 addition & 0 deletions sqlit/domains/connections/providers/adapter_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def build_adapter_provider(spec: ProviderSpec, schema: ConnectionSchema, adapter
default_schema=str(getattr(adapter, "default_schema", "")),
system_databases=frozenset(getattr(adapter, "system_databases", frozenset())),
supports_foreign_keys=bool(getattr(adapter, "supports_foreign_keys", False)),
supports_schema_grouping=bool(getattr(adapter, "supports_schema_grouping", False)),
)

def display_info(config: ConnectionConfig) -> str:
Expand Down
3 changes: 3 additions & 0 deletions sqlit/domains/connections/providers/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class IndexInfo:
name: str
table_name: str
is_unique: bool = False
schema: str = ""


@dataclass
Expand All @@ -125,13 +126,15 @@ class TriggerInfo:

name: str
table_name: str
schema: str = ""


@dataclass
class SequenceInfo:
"""Information about a database sequence."""

name: str
schema: str = ""


@dataclass(frozen=True)
Expand Down
1 change: 1 addition & 0 deletions sqlit/domains/connections/providers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class SchemaCapabilities:
default_schema: str
system_databases: frozenset[str]
supports_foreign_keys: bool = False
supports_schema_grouping: bool = False


@runtime_checkable
Expand Down
53 changes: 35 additions & 18 deletions sqlit/domains/connections/providers/mssql/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,14 +510,26 @@ def get_columns(
)
return [ColumnInfo(name=row[0], data_type=row[1], is_primary_key=row[0] in pk_columns) for row in cursor.fetchall()]

supports_schema_grouping = True

def get_schemas(self, conn: Any, database: str | None = None) -> list[str]:
cursor = self._get_cursor_for_database(conn, database)
cursor.execute(
"SELECT name FROM sys.schemas WHERE schema_id < 16384 "
"AND name NOT IN ('guest', 'sys', 'INFORMATION_SCHEMA') ORDER BY name"
)
return [row[0] for row in cursor.fetchall()]

def get_procedures(self, conn: Any, database: str | None = None) -> list[str]:
"""Get stored procedures from SQL Server."""
cursor = self._get_cursor_for_database(conn, database)
cursor.execute(
"SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES "
"SELECT ROUTINE_NAME, ROUTINE_SCHEMA FROM INFORMATION_SCHEMA.ROUTINES "
"WHERE ROUTINE_TYPE = 'PROCEDURE' ORDER BY ROUTINE_NAME"
)
return [row[0] for row in cursor.fetchall()]
from sqlit.domains.connections.providers.adapters.base import RoutineInfo

return [RoutineInfo(row[0], schema=row[1]) for row in cursor.fetchall()]

def get_completion_routines(
self, conn: Any, database: str | None = None
Expand Down Expand Up @@ -560,30 +572,30 @@ def get_indexes(self, conn: Any, database: str | None = None) -> list[IndexInfo]
"""Get indexes from SQL Server."""
cursor = self._get_cursor_for_database(conn, database)
cursor.execute(
"SELECT i.name, t.name, i.is_unique "
"SELECT i.name, t.name, i.is_unique, SCHEMA_NAME(t.schema_id) "
"FROM sys.indexes i "
"JOIN sys.tables t ON i.object_id = t.object_id "
"WHERE i.name IS NOT NULL AND i.type > 0 AND i.is_primary_key = 0 "
"ORDER BY t.name, i.name"
)
return [IndexInfo(name=row[0], table_name=row[1], is_unique=row[2]) for row in cursor.fetchall()]
return [IndexInfo(name=row[0], table_name=row[1], is_unique=row[2], schema=row[3]) for row in cursor.fetchall()]

def get_triggers(self, conn: Any, database: str | None = None) -> list[TriggerInfo]:
"""Get triggers from SQL Server."""
cursor = self._get_cursor_for_database(conn, database)
cursor.execute(
"SELECT tr.name, OBJECT_NAME(tr.parent_id) "
"SELECT tr.name, OBJECT_NAME(tr.parent_id), OBJECT_SCHEMA_NAME(tr.parent_id) "
"FROM sys.triggers tr "
"WHERE tr.is_ms_shipped = 0 AND tr.parent_id > 0 "
"ORDER BY OBJECT_NAME(tr.parent_id), tr.name"
)
return [TriggerInfo(name=row[0], table_name=row[1] or "") for row in cursor.fetchall()]
return [TriggerInfo(name=row[0], table_name=row[1] or "", schema=row[2]) for row in cursor.fetchall()]

def get_sequences(self, conn: Any, database: str | None = None) -> list[SequenceInfo]:
"""Get sequences from SQL Server (2012+)."""
cursor = self._get_cursor_for_database(conn, database)
cursor.execute("SELECT name FROM sys.sequences ORDER BY name")
return [SequenceInfo(name=row[0]) for row in cursor.fetchall()]
cursor.execute("SELECT name, SCHEMA_NAME(schema_id) FROM sys.sequences ORDER BY name")
return [SequenceInfo(name=row[0], schema=row[1]) for row in cursor.fetchall()]

def get_foreign_keys(
self,
Expand Down Expand Up @@ -665,7 +677,7 @@ def get_referencing_foreign_keys(
]

def get_index_definition(
self, conn: Any, index_name: str, table_name: str, database: str | None = None
self, conn: Any, index_name: str, table_name: str, database: str | None = None, schema: str | None = None
) -> dict[str, Any]:
"""Get detailed information about a SQL Server index."""
cursor = self._get_cursor_for_database(conn, database)
Expand All @@ -676,8 +688,9 @@ def get_index_definition(
"JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id "
"JOIN sys.tables t ON i.object_id = t.object_id "
"WHERE i.name = ? AND t.name = ? "
"ORDER BY ic.key_ordinal",
(index_name, table_name),
+ ("AND SCHEMA_NAME(t.schema_id) = ? " if schema is not None else "")
+ "ORDER BY ic.key_ordinal",
(index_name, table_name) + ((schema,) if schema is not None else ()),
)
rows = cursor.fetchall()
is_unique = rows[0][0] if rows else False
Expand All @@ -692,12 +705,14 @@ def get_index_definition(
"type": index_type,
"definition": (
f"CREATE {'UNIQUE ' if is_unique else ''}{index_type} INDEX "
f"[{index_name}] ON [{table_name}] ({', '.join(f'[{c}]' for c in columns)})"
f"{self.quote_identifier(index_name)} ON "
f"{self.quote_identifier(schema) + '.' if schema is not None else ''}"
f"{self.quote_identifier(table_name)} ({', '.join(self.quote_identifier(c) for c in columns)})"
),
}

def get_trigger_definition(
self, conn: Any, trigger_name: str, table_name: str, database: str | None = None
self, conn: Any, trigger_name: str, table_name: str, database: str | None = None, schema: str | None = None
) -> dict[str, Any]:
"""Get detailed information about a SQL Server trigger."""
cursor = self._get_cursor_for_database(conn, database)
Expand All @@ -707,8 +722,9 @@ def get_trigger_definition(
" ELSE 'AFTER' END as timing "
"FROM sys.triggers tr "
"JOIN sys.tables t ON tr.parent_id = t.object_id "
"WHERE tr.name = ? AND t.name = ?",
(trigger_name, table_name),
"WHERE tr.name = ? AND t.name = ?"
+ (" AND SCHEMA_NAME(t.schema_id) = ?" if schema is not None else ""),
(trigger_name, table_name) + ((schema,) if schema is not None else ()),
)
row = cursor.fetchone()
if row:
Expand Down Expand Up @@ -742,15 +758,16 @@ def get_trigger_definition(
}

def get_sequence_definition(
self, conn: Any, sequence_name: str, database: str | None = None
self, conn: Any, sequence_name: str, database: str | None = None, schema: str | None = None
) -> dict[str, Any]:
"""Get detailed information about a SQL Server sequence."""
cursor = self._get_cursor_for_database(conn, database)
cursor.execute(
"SELECT CAST(start_value AS BIGINT), CAST(increment AS BIGINT), "
"CAST(minimum_value AS BIGINT), CAST(maximum_value AS BIGINT), is_cycling "
"FROM sys.sequences WHERE name = ?",
(sequence_name,),
"FROM sys.sequences WHERE name = ?"
+ (" AND SCHEMA_NAME(schema_id) = ?" if schema is not None else ""),
(sequence_name,) + ((schema,) if schema is not None else ()),
)
row = cursor.fetchone()
if row:
Expand Down
20 changes: 17 additions & 3 deletions sqlit/domains/connections/providers/postgresql/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,26 @@ def get_databases(self, conn: Any) -> list[str]:
cursor.execute("SELECT datname FROM pg_database " "WHERE datistemplate = false ORDER BY datname")
return [row[0] for row in cursor.fetchall()]

supports_schema_grouping = True

def get_schemas(self, conn: Any, database: str | None = None) -> list[str]:
cursor = conn.cursor()
cursor.execute(
"SELECT schema_name FROM information_schema.schemata "
"WHERE schema_name NOT IN ('pg_catalog', 'information_schema') "
"AND schema_name NOT LIKE 'pg_toast%' AND schema_name NOT LIKE 'pg_temp_%' "
"ORDER BY schema_name"
)
return [row[0] for row in cursor.fetchall()]

def get_procedures(self, conn: Any, database: str | None = None) -> list[str]:
"""Get stored procedures/functions from PostgreSQL."""
cursor = conn.cursor()
cursor.execute(
"SELECT routine_name FROM information_schema.routines "
"WHERE routine_schema = 'public' AND routine_type = 'FUNCTION' "
"SELECT routine_name, routine_schema FROM information_schema.routines "
"WHERE routine_schema NOT IN ('pg_catalog', 'information_schema') "
"ORDER BY routine_name"
)
return [row[0] for row in cursor.fetchall()]
from sqlit.domains.connections.providers.adapters.base import RoutineInfo

return [RoutineInfo(row[0], schema=row[1]) for row in cursor.fetchall()]
40 changes: 22 additions & 18 deletions sqlit/domains/connections/providers/postgresql/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,21 +155,21 @@ def get_indexes(self, conn: Any, database: str | None = None) -> list[IndexInfo]
cursor = conn.cursor()
cursor.execute(
"SELECT indexname, tablename, "
" CASE WHEN indexdef LIKE '%UNIQUE%' THEN true ELSE false END as is_unique "
" CASE WHEN indexdef LIKE '%UNIQUE%' THEN true ELSE false END as is_unique, schemaname "
"FROM pg_indexes "
"WHERE schemaname NOT IN ('pg_catalog', 'information_schema') "
"ORDER BY tablename, indexname"
)
return [
IndexInfo(name=row[0], table_name=row[1], is_unique=row[2])
IndexInfo(name=row[0], table_name=row[1], is_unique=row[2], schema=row[3])
for row in cursor.fetchall()
]

def get_triggers(self, conn: Any, database: str | None = None) -> list[TriggerInfo]:
"""Get triggers from PostgreSQL."""
cursor = conn.cursor()
cursor.execute(
"SELECT trigger_name, event_object_table "
"SELECT trigger_name, event_object_table, trigger_schema "
"FROM information_schema.triggers "
"WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') "
"ORDER BY event_object_table, trigger_name"
Expand All @@ -178,34 +178,35 @@ def get_triggers(self, conn: Any, database: str | None = None) -> list[TriggerIn
seen = set()
results = []
for row in cursor.fetchall():
key = (row[0], row[1])
key = (row[0], row[1], row[2])
if key not in seen:
seen.add(key)
results.append(TriggerInfo(name=row[0], table_name=row[1]))
results.append(TriggerInfo(name=row[0], table_name=row[1], schema=row[2]))
return results

def get_sequences(self, conn: Any, database: str | None = None) -> list[SequenceInfo]:
"""Get sequences from PostgreSQL."""
cursor = conn.cursor()
cursor.execute(
"SELECT sequence_name "
"SELECT sequence_name, sequence_schema "
"FROM information_schema.sequences "
"WHERE sequence_schema NOT IN ('pg_catalog', 'information_schema') "
"ORDER BY sequence_name"
)
return [SequenceInfo(name=row[0]) for row in cursor.fetchall()]
return [SequenceInfo(name=row[0], schema=row[1]) for row in cursor.fetchall()]

def get_index_definition(
self, conn: Any, index_name: str, table_name: str, database: str | None = None
self, conn: Any, index_name: str, table_name: str, database: str | None = None, schema: str | None = None
) -> dict[str, Any]:
"""Get detailed information about a PostgreSQL index."""
cursor = conn.cursor()
cursor.execute(
"SELECT indexdef, "
" CASE WHEN indexdef LIKE '%%UNIQUE%%' THEN true ELSE false END as is_unique "
"FROM pg_indexes "
"WHERE indexname = %s AND tablename = %s",
(index_name, table_name),
"WHERE indexname = %s AND tablename = %s"
+ (" AND schemaname = %s" if schema is not None else ""),
(index_name, table_name) + ((schema,) if schema is not None else ()),
)
row = cursor.fetchone()
if row:
Expand All @@ -225,16 +226,17 @@ def get_index_definition(
}

def get_trigger_definition(
self, conn: Any, trigger_name: str, table_name: str, database: str | None = None
self, conn: Any, trigger_name: str, table_name: str, database: str | None = None, schema: str | None = None
) -> dict[str, Any]:
"""Get detailed information about a PostgreSQL trigger."""
cursor = conn.cursor()
cursor.execute(
"SELECT action_timing, event_manipulation, action_statement "
"FROM information_schema.triggers "
"WHERE trigger_name = %s AND event_object_table = %s "
"LIMIT 1",
(trigger_name, table_name),
+ ("AND trigger_schema = %s " if schema is not None else "")
+ "LIMIT 1",
(trigger_name, table_name) + ((schema,) if schema is not None else ()),
)
row = cursor.fetchone()
if row:
Expand All @@ -244,8 +246,9 @@ def get_trigger_definition(
"SELECT pg_get_triggerdef(t.oid) "
"FROM pg_trigger t "
"JOIN pg_class c ON t.tgrelid = c.oid "
"WHERE t.tgname = %s AND c.relname = %s",
(trigger_name, table_name),
"WHERE t.tgname = %s AND c.relname = %s"
+ (" AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = %s)" if schema is not None else ""),
(trigger_name, table_name) + ((schema,) if schema is not None else ()),
)
def_row = cursor.fetchone()
definition = def_row[0] if def_row else row[2]
Expand Down Expand Up @@ -367,16 +370,17 @@ def get_referencing_foreign_keys(
]

def get_sequence_definition(
self, conn: Any, sequence_name: str, database: str | None = None
self, conn: Any, sequence_name: str, database: str | None = None, schema: str | None = None
) -> dict[str, Any]:
"""Get detailed information about a PostgreSQL sequence."""
cursor = conn.cursor()
cursor.execute(
"SELECT start_value, increment, minimum_value, maximum_value, cycle_option "
"FROM information_schema.sequences "
"WHERE sequence_name = %s "
"AND sequence_schema NOT IN ('pg_catalog', 'information_schema')",
(sequence_name,),
"AND sequence_schema NOT IN ('pg_catalog', 'information_schema')"
+ (" AND sequence_schema = %s" if schema is not None else ""),
(sequence_name,) + ((schema,) if schema is not None else ()),
)
row = cursor.fetchone()
if row:
Expand Down
Loading
Loading