sqlsaber 0.34.0__tar.gz → 0.35.0__tar.gz
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.
Potentially problematic release.
This version of sqlsaber might be problematic. Click here for more details.
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/PKG-INFO +1 -1
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/changelog.md +17 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/pyproject.toml +1 -1
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/display.py +19 -3
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/interactive.py +4 -2
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/base.py +2 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/duckdb.py +41 -26
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/mysql.py +7 -3
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/postgresql.py +7 -3
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/schema.py +3 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/sqlite.py +18 -5
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/sql_tools.py +32 -21
- sqlsaber-0.35.0/tests/test_database/test_schema.py +110 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_schema_display.py +33 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/uv.lock +1 -1
- sqlsaber-0.34.0/tests/test_database/test_schema.py +0 -47
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/claude-code-review.yml +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/claude.yml +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/deploy-docs.yml +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/publish.yml +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/test.yml +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.gitignore +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.python-version +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/AGENTS.md +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/CLAUDE.md +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/LICENSE +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/README.md +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/.gitignore +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/.vscode/extensions.json +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/.vscode/launch.json +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/CLAUDE.md +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/astro.config.mjs +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/package-lock.json +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/package.json +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/public/CNAME +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/public/favicon.svg +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/assets/sqlsaber.gif +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/authentication.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/database-setup.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/getting-started.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/memory.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/models.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/queries.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/threads.md +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/index.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/installation.mdx +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/reference/commands.md +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content.config.ts +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/styles/global.css +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/tsconfig.json +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/legislators.db +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/pytest.ini +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/sqlsaber.gif +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/sqlsaber.svg +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/__main__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/agents/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/agents/base.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/agents/pydantic_ai_agent.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/auth_setup.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/db_setup.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/model_selection.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/prompts.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/auth.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/commands.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/completers.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/database.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/memory.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/models.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/onboarding.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/streaming.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/theme.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/threads.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/api_keys.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/auth.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/database.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/logging.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/oauth_flow.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/oauth_tokens.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/providers.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/settings.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/csv.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/resolver.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/memory/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/memory/manager.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/memory/storage.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/claude.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/memory.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/openai.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/theme/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/theme/manager.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/threads/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/threads/storage.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/base.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/registry.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/sql_guard.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/conftest.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_application/test_auth_setup.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/test_auth_reset.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/test_commands.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/test_threads.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_database.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_oauth.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_providers.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_settings.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_connection.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_csv_connection.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_csv_module.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_duckdb_module.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_postgresql_module.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_sqlite_module.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_timeout.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database_resolver.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_theme/test_manager.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_threads_storage.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/__init__.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_base.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_registry.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_sql_guard.py +0 -0
- {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_sql_tools.py +0 -0
|
@@ -9,6 +9,23 @@ All notable changes to SQLsaber will be documented here.
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
### v0.35.0 - 2025-10-22
|
|
13
|
+
|
|
14
|
+
#### Added
|
|
15
|
+
|
|
16
|
+
- Table and column comment support across all databases
|
|
17
|
+
- Comments are now included in schema introspection to provide richer context to LLM
|
|
18
|
+
- PostgreSQL: Uses `obj_description()` and `col_description()` functions
|
|
19
|
+
- MySQL: Retrieves `table_comment` and `column_comment` from `information_schema`
|
|
20
|
+
- DuckDB: Joins with `duckdb_tables()` and `duckdb_columns()` for comment data
|
|
21
|
+
- SQLite: Returns `None` for comments (SQLite doesn't support native comments)
|
|
22
|
+
- Comments are conditionally included in tool output only when present, avoiding clutter
|
|
23
|
+
- Updated `ColumnInfo` and `SchemaInfo` TypedDicts with optional comment fields
|
|
24
|
+
|
|
25
|
+
#### Fixed
|
|
26
|
+
|
|
27
|
+
- Strip leading/trailing whitespace and new lines from submitted user inputs
|
|
28
|
+
|
|
12
29
|
### v0.34.0 - 2025-10-17
|
|
13
30
|
|
|
14
31
|
#### Changed
|
|
@@ -406,9 +406,17 @@ class DisplayManager:
|
|
|
406
406
|
for table_name, table_info in data.items():
|
|
407
407
|
self.console.print(f"\n[heading]Table: {table_name}[/heading]")
|
|
408
408
|
|
|
409
|
+
table_comment = table_info.get("comment")
|
|
410
|
+
if table_comment:
|
|
411
|
+
self.console.print(f"[muted]Comment: {table_comment}[/muted]")
|
|
412
|
+
|
|
409
413
|
# Show columns
|
|
410
414
|
table_columns = table_info.get("columns", {})
|
|
411
415
|
if table_columns:
|
|
416
|
+
include_column_comments = any(
|
|
417
|
+
col_info.get("comment") for col_info in table_columns.values()
|
|
418
|
+
)
|
|
419
|
+
|
|
412
420
|
# Create a table for columns
|
|
413
421
|
columns = [
|
|
414
422
|
{"name": "Column Name", "style": "column.name"},
|
|
@@ -416,6 +424,8 @@ class DisplayManager:
|
|
|
416
424
|
{"name": "Nullable", "style": "info"},
|
|
417
425
|
{"name": "Default", "style": "muted"},
|
|
418
426
|
]
|
|
427
|
+
if include_column_comments:
|
|
428
|
+
columns.append({"name": "Comment", "style": "muted"})
|
|
419
429
|
col_table = self._create_table(columns, title="Columns")
|
|
420
430
|
|
|
421
431
|
for col_name, col_info in table_columns.items():
|
|
@@ -425,9 +435,15 @@ class DisplayManager:
|
|
|
425
435
|
if col_info.get("default")
|
|
426
436
|
else ""
|
|
427
437
|
)
|
|
428
|
-
|
|
429
|
-
col_name,
|
|
430
|
-
|
|
438
|
+
row = [
|
|
439
|
+
col_name,
|
|
440
|
+
col_info.get("type", ""),
|
|
441
|
+
nullable,
|
|
442
|
+
default,
|
|
443
|
+
]
|
|
444
|
+
if include_column_comments:
|
|
445
|
+
row.append(col_info.get("comment") or "")
|
|
446
|
+
col_table.add_row(*row)
|
|
431
447
|
|
|
432
448
|
self.console.print(col_table)
|
|
433
449
|
|
|
@@ -20,6 +20,7 @@ from sqlsaber.cli.completers import (
|
|
|
20
20
|
)
|
|
21
21
|
from sqlsaber.cli.display import DisplayManager
|
|
22
22
|
from sqlsaber.cli.streaming import StreamingQueryHandler
|
|
23
|
+
from sqlsaber.config.logging import get_logger
|
|
23
24
|
from sqlsaber.database import (
|
|
24
25
|
CSVConnection,
|
|
25
26
|
DuckDBConnection,
|
|
@@ -30,7 +31,6 @@ from sqlsaber.database import (
|
|
|
30
31
|
from sqlsaber.database.schema import SchemaManager
|
|
31
32
|
from sqlsaber.theme.manager import get_theme_manager
|
|
32
33
|
from sqlsaber.threads import ThreadStorage
|
|
33
|
-
from sqlsaber.config.logging import get_logger
|
|
34
34
|
|
|
35
35
|
if TYPE_CHECKING:
|
|
36
36
|
from sqlsaber.agents.pydantic_ai_agent import SQLSaberAgent
|
|
@@ -309,6 +309,8 @@ class InteractiveSession:
|
|
|
309
309
|
style=self.tm.pt_style(),
|
|
310
310
|
)
|
|
311
311
|
|
|
312
|
+
user_query = user_query.strip()
|
|
313
|
+
|
|
312
314
|
if not user_query:
|
|
313
315
|
continue
|
|
314
316
|
|
|
@@ -325,7 +327,7 @@ class InteractiveSession:
|
|
|
325
327
|
|
|
326
328
|
# Handle memory addition
|
|
327
329
|
if user_query.strip().startswith("#"):
|
|
328
|
-
await self._handle_memory(user_query
|
|
330
|
+
await self._handle_memory(user_query[1:].strip())
|
|
329
331
|
continue
|
|
330
332
|
|
|
331
333
|
# Execute query with cancellation support
|
|
@@ -24,6 +24,7 @@ class ColumnInfo(TypedDict):
|
|
|
24
24
|
max_length: int | None
|
|
25
25
|
precision: int | None
|
|
26
26
|
scale: int | None
|
|
27
|
+
comment: str | None
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
class ForeignKeyInfo(TypedDict):
|
|
@@ -48,6 +49,7 @@ class SchemaInfo(TypedDict):
|
|
|
48
49
|
schema: str
|
|
49
50
|
name: str
|
|
50
51
|
type: str
|
|
52
|
+
comment: str | None
|
|
51
53
|
columns: dict[str, ColumnInfo]
|
|
52
54
|
primary_keys: list[str]
|
|
53
55
|
foreign_keys: list[ForeignKeyInfo]
|
|
@@ -132,29 +132,35 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
132
132
|
) -> list[dict[str, Any]]:
|
|
133
133
|
"""Get tables information for DuckDB."""
|
|
134
134
|
where_conditions = [
|
|
135
|
-
"table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')"
|
|
135
|
+
"t.table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')"
|
|
136
136
|
]
|
|
137
137
|
params: list[Any] = []
|
|
138
138
|
|
|
139
139
|
if table_pattern:
|
|
140
140
|
if "." in table_pattern:
|
|
141
141
|
schema_pattern, table_name_pattern = table_pattern.split(".", 1)
|
|
142
|
-
where_conditions.append(
|
|
142
|
+
where_conditions.append(
|
|
143
|
+
"(t.table_schema LIKE ? AND t.table_name LIKE ?)"
|
|
144
|
+
)
|
|
143
145
|
params.extend([schema_pattern, table_name_pattern])
|
|
144
146
|
else:
|
|
145
147
|
where_conditions.append(
|
|
146
|
-
"(table_name LIKE ? OR table_schema || '.' || table_name LIKE ?)"
|
|
148
|
+
"(t.table_name LIKE ? OR t.table_schema || '.' || t.table_name LIKE ?)"
|
|
147
149
|
)
|
|
148
150
|
params.extend([table_pattern, table_pattern])
|
|
149
151
|
|
|
150
152
|
query = f"""
|
|
151
153
|
SELECT
|
|
152
|
-
table_schema,
|
|
153
|
-
table_name,
|
|
154
|
-
table_type
|
|
155
|
-
|
|
154
|
+
t.table_schema,
|
|
155
|
+
t.table_name,
|
|
156
|
+
t.table_type,
|
|
157
|
+
dt.comment AS table_comment
|
|
158
|
+
FROM information_schema.tables t
|
|
159
|
+
LEFT JOIN duckdb_tables() dt
|
|
160
|
+
ON t.table_schema = dt.schema_name
|
|
161
|
+
AND t.table_name = dt.table_name
|
|
156
162
|
WHERE {" AND ".join(where_conditions)}
|
|
157
|
-
ORDER BY table_schema, table_name;
|
|
163
|
+
ORDER BY t.table_schema, t.table_name;
|
|
158
164
|
"""
|
|
159
165
|
|
|
160
166
|
return await self._execute_query(connection, query, tuple(params))
|
|
@@ -166,7 +172,7 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
166
172
|
|
|
167
173
|
table_filters = []
|
|
168
174
|
for table in tables:
|
|
169
|
-
table_filters.append("(table_schema = ? AND table_name = ?)")
|
|
175
|
+
table_filters.append("(c.table_schema = ? AND c.table_name = ?)")
|
|
170
176
|
|
|
171
177
|
params: list[Any] = []
|
|
172
178
|
for table in tables:
|
|
@@ -174,18 +180,23 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
174
180
|
|
|
175
181
|
query = f"""
|
|
176
182
|
SELECT
|
|
177
|
-
table_schema,
|
|
178
|
-
table_name,
|
|
179
|
-
column_name,
|
|
180
|
-
data_type,
|
|
181
|
-
is_nullable,
|
|
182
|
-
column_default,
|
|
183
|
-
character_maximum_length,
|
|
184
|
-
numeric_precision,
|
|
185
|
-
numeric_scale
|
|
186
|
-
|
|
183
|
+
c.table_schema,
|
|
184
|
+
c.table_name,
|
|
185
|
+
c.column_name,
|
|
186
|
+
c.data_type,
|
|
187
|
+
c.is_nullable,
|
|
188
|
+
c.column_default,
|
|
189
|
+
c.character_maximum_length,
|
|
190
|
+
c.numeric_precision,
|
|
191
|
+
c.numeric_scale,
|
|
192
|
+
dc.comment AS column_comment
|
|
193
|
+
FROM information_schema.columns c
|
|
194
|
+
LEFT JOIN duckdb_columns() dc
|
|
195
|
+
ON c.table_schema = dc.schema_name
|
|
196
|
+
AND c.table_name = dc.table_name
|
|
197
|
+
AND c.column_name = dc.column_name
|
|
187
198
|
WHERE {" OR ".join(table_filters)}
|
|
188
|
-
ORDER BY table_schema, table_name, ordinal_position;
|
|
199
|
+
ORDER BY c.table_schema, c.table_name, c.ordinal_position;
|
|
189
200
|
"""
|
|
190
201
|
|
|
191
202
|
return await self._execute_query(connection, query, tuple(params))
|
|
@@ -307,12 +318,16 @@ class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
307
318
|
"""Get list of tables with basic information for DuckDB."""
|
|
308
319
|
query = """
|
|
309
320
|
SELECT
|
|
310
|
-
table_schema,
|
|
311
|
-
table_name,
|
|
312
|
-
table_type
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
321
|
+
t.table_schema,
|
|
322
|
+
t.table_name,
|
|
323
|
+
t.table_type,
|
|
324
|
+
dt.comment AS table_comment
|
|
325
|
+
FROM information_schema.tables t
|
|
326
|
+
LEFT JOIN duckdb_tables() dt
|
|
327
|
+
ON t.table_schema = dt.schema_name
|
|
328
|
+
AND t.table_name = dt.table_name
|
|
329
|
+
WHERE t.table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')
|
|
330
|
+
ORDER BY t.table_schema, t.table_name;
|
|
316
331
|
"""
|
|
317
332
|
|
|
318
333
|
return await self._execute_query(connection, query)
|
|
@@ -202,7 +202,8 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
202
202
|
SELECT
|
|
203
203
|
table_schema,
|
|
204
204
|
table_name,
|
|
205
|
-
table_type
|
|
205
|
+
table_type,
|
|
206
|
+
table_comment
|
|
206
207
|
FROM information_schema.tables
|
|
207
208
|
WHERE {" AND ".join(where_conditions)}
|
|
208
209
|
ORDER BY table_schema, table_name;
|
|
@@ -230,7 +231,8 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
230
231
|
c.column_default,
|
|
231
232
|
c.character_maximum_length,
|
|
232
233
|
c.numeric_precision,
|
|
233
|
-
c.numeric_scale
|
|
234
|
+
c.numeric_scale,
|
|
235
|
+
c.column_comment
|
|
234
236
|
FROM information_schema.columns c
|
|
235
237
|
WHERE (c.table_schema, c.table_name) IN ({placeholders})
|
|
236
238
|
ORDER BY c.table_schema, c.table_name, c.ordinal_position;
|
|
@@ -331,7 +333,8 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
331
333
|
SELECT
|
|
332
334
|
t.table_schema,
|
|
333
335
|
t.table_name,
|
|
334
|
-
t.table_type
|
|
336
|
+
t.table_type,
|
|
337
|
+
t.table_comment
|
|
335
338
|
FROM information_schema.tables t
|
|
336
339
|
WHERE t.table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
|
|
337
340
|
ORDER BY t.table_schema, t.table_name;
|
|
@@ -345,6 +348,7 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
345
348
|
"table_schema": row["table_schema"],
|
|
346
349
|
"table_name": row["table_name"],
|
|
347
350
|
"table_type": row["table_type"],
|
|
351
|
+
"table_comment": row["table_comment"],
|
|
348
352
|
}
|
|
349
353
|
for row in rows
|
|
350
354
|
]
|
|
@@ -226,7 +226,8 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
226
226
|
SELECT
|
|
227
227
|
table_schema,
|
|
228
228
|
table_name,
|
|
229
|
-
table_type
|
|
229
|
+
table_type,
|
|
230
|
+
obj_description(('"' || table_schema || '"."' || table_name || '"')::regclass, 'pg_class') AS table_comment
|
|
230
231
|
FROM information_schema.tables
|
|
231
232
|
WHERE {" AND ".join(where_conditions)}
|
|
232
233
|
ORDER BY table_schema, table_name;
|
|
@@ -252,7 +253,8 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
252
253
|
c.column_default,
|
|
253
254
|
c.character_maximum_length,
|
|
254
255
|
c.numeric_precision,
|
|
255
|
-
c.numeric_scale
|
|
256
|
+
c.numeric_scale,
|
|
257
|
+
col_description(('"' || c.table_schema || '"."' || c.table_name || '"')::regclass::oid, c.ordinal_position::INT) AS column_comment
|
|
256
258
|
FROM information_schema.columns c
|
|
257
259
|
WHERE (c.table_schema, c.table_name) IN (VALUES {values_clause})
|
|
258
260
|
ORDER BY c.table_schema, c.table_name, c.ordinal_position;
|
|
@@ -367,7 +369,8 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
367
369
|
SELECT
|
|
368
370
|
table_schema,
|
|
369
371
|
table_name,
|
|
370
|
-
table_type
|
|
372
|
+
table_type,
|
|
373
|
+
obj_description(('"' || table_schema || '"."' || table_name || '"')::regclass, 'pg_class') AS table_comment
|
|
371
374
|
FROM information_schema.tables
|
|
372
375
|
WHERE {where_clause}
|
|
373
376
|
ORDER BY table_schema, table_name;
|
|
@@ -380,6 +383,7 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
380
383
|
"table_schema": table["table_schema"],
|
|
381
384
|
"table_name": table["table_name"],
|
|
382
385
|
"table_type": table["table_type"],
|
|
386
|
+
"table_comment": table["table_comment"],
|
|
383
387
|
}
|
|
384
388
|
for table in tables
|
|
385
389
|
]
|
|
@@ -72,6 +72,7 @@ class SchemaManager:
|
|
|
72
72
|
"schema": schema_name,
|
|
73
73
|
"name": table_name,
|
|
74
74
|
"type": table["table_type"],
|
|
75
|
+
"comment": table["table_comment"],
|
|
75
76
|
"columns": {},
|
|
76
77
|
"primary_keys": [],
|
|
77
78
|
"foreign_keys": [],
|
|
@@ -85,6 +86,7 @@ class SchemaManager:
|
|
|
85
86
|
for col in columns:
|
|
86
87
|
full_name = f"{col['table_schema']}.{col['table_name']}"
|
|
87
88
|
if full_name in schema_info:
|
|
89
|
+
# Handle different row types (dict vs Row objects)
|
|
88
90
|
column_info: ColumnInfo = {
|
|
89
91
|
"data_type": col["data_type"],
|
|
90
92
|
"nullable": col.get("is_nullable", "YES") == "YES",
|
|
@@ -92,6 +94,7 @@ class SchemaManager:
|
|
|
92
94
|
"max_length": col.get("character_maximum_length"),
|
|
93
95
|
"precision": col.get("numeric_precision"),
|
|
94
96
|
"scale": col.get("numeric_scale"),
|
|
97
|
+
"comment": col.get("column_comment"),
|
|
95
98
|
}
|
|
96
99
|
# Add type field for display compatibility
|
|
97
100
|
column_info["type"] = col["data_type"]
|
|
@@ -93,7 +93,10 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
93
93
|
async def get_tables_info(
|
|
94
94
|
self, connection, table_pattern: str | None = None
|
|
95
95
|
) -> dict[str, Any]:
|
|
96
|
-
"""Get tables information for SQLite.
|
|
96
|
+
"""Get tables information for SQLite.
|
|
97
|
+
|
|
98
|
+
Note: SQLite does not support native table comments, so table_comment is always None.
|
|
99
|
+
"""
|
|
97
100
|
where_conditions = ["type IN ('table', 'view')", "name NOT LIKE 'sqlite_%'"]
|
|
98
101
|
params = ()
|
|
99
102
|
|
|
@@ -105,7 +108,8 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
105
108
|
SELECT
|
|
106
109
|
'main' as table_schema,
|
|
107
110
|
name as table_name,
|
|
108
|
-
type as table_type
|
|
111
|
+
type as table_type,
|
|
112
|
+
NULL as table_comment
|
|
109
113
|
FROM sqlite_master
|
|
110
114
|
WHERE {" AND ".join(where_conditions)}
|
|
111
115
|
ORDER BY name;
|
|
@@ -114,7 +118,10 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
114
118
|
return await self._execute_query(connection, query, params)
|
|
115
119
|
|
|
116
120
|
async def get_columns_info(self, connection, tables: list) -> list:
|
|
117
|
-
"""Get columns information for SQLite.
|
|
121
|
+
"""Get columns information for SQLite.
|
|
122
|
+
|
|
123
|
+
Note: SQLite does not support native column comments, so column_comment is always None.
|
|
124
|
+
"""
|
|
118
125
|
if not tables:
|
|
119
126
|
return []
|
|
120
127
|
|
|
@@ -138,6 +145,7 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
138
145
|
"character_maximum_length": None,
|
|
139
146
|
"numeric_precision": None,
|
|
140
147
|
"numeric_scale": None,
|
|
148
|
+
"column_comment": None,
|
|
141
149
|
}
|
|
142
150
|
)
|
|
143
151
|
|
|
@@ -237,13 +245,17 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
237
245
|
return indexes
|
|
238
246
|
|
|
239
247
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
240
|
-
"""Get list of tables with basic information for SQLite.
|
|
248
|
+
"""Get list of tables with basic information for SQLite.
|
|
249
|
+
|
|
250
|
+
Note: SQLite does not support native table comments, so table_comment is always None.
|
|
251
|
+
"""
|
|
241
252
|
# Get table names without row counts for better performance
|
|
242
253
|
tables_query = """
|
|
243
254
|
SELECT
|
|
244
255
|
'main' as table_schema,
|
|
245
256
|
name as table_name,
|
|
246
|
-
type as table_type
|
|
257
|
+
type as table_type,
|
|
258
|
+
NULL as table_comment
|
|
247
259
|
FROM sqlite_master
|
|
248
260
|
WHERE type IN ('table', 'view')
|
|
249
261
|
AND name NOT LIKE 'sqlite_%'
|
|
@@ -258,6 +270,7 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
258
270
|
"table_schema": table["table_schema"],
|
|
259
271
|
"table_name": table["table_name"],
|
|
260
272
|
"table_type": table["table_type"],
|
|
273
|
+
"table_comment": table["table_comment"],
|
|
261
274
|
}
|
|
262
275
|
for table in tables
|
|
263
276
|
]
|
|
@@ -95,27 +95,38 @@ class IntrospectSchemaTool(SQLTool):
|
|
|
95
95
|
# Format the schema information
|
|
96
96
|
formatted_info = {}
|
|
97
97
|
for table_name, table_info in schema_info.items():
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
98
|
+
table_data = {}
|
|
99
|
+
|
|
100
|
+
# Add table comment if present
|
|
101
|
+
if table_info.get("comment"):
|
|
102
|
+
table_data["comment"] = table_info["comment"]
|
|
103
|
+
|
|
104
|
+
# Add columns with comments if present
|
|
105
|
+
table_data["columns"] = {}
|
|
106
|
+
for col_name, col_info in table_info["columns"].items():
|
|
107
|
+
column_data = {
|
|
108
|
+
"type": col_info["data_type"],
|
|
109
|
+
"nullable": col_info["nullable"],
|
|
110
|
+
"default": col_info["default"],
|
|
111
|
+
}
|
|
112
|
+
if col_info.get("comment"):
|
|
113
|
+
column_data["comment"] = col_info["comment"]
|
|
114
|
+
table_data["columns"][col_name] = column_data
|
|
115
|
+
|
|
116
|
+
# Add other schema information
|
|
117
|
+
table_data["primary_keys"] = table_info["primary_keys"]
|
|
118
|
+
table_data["foreign_keys"] = [
|
|
119
|
+
f"{fk['column']} -> {fk['references']['table']}.{fk['references']['column']}"
|
|
120
|
+
for fk in table_info["foreign_keys"]
|
|
121
|
+
]
|
|
122
|
+
table_data["indexes"] = [
|
|
123
|
+
f"{idx['name']} ({', '.join(idx['columns'])})"
|
|
124
|
+
+ (" UNIQUE" if idx["unique"] else "")
|
|
125
|
+
+ (f" [{idx['type']}]" if idx["type"] else "")
|
|
126
|
+
for idx in table_info["indexes"]
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
formatted_info[table_name] = table_data
|
|
119
130
|
|
|
120
131
|
return json.dumps(formatted_info)
|
|
121
132
|
except Exception as e:
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Tests for schema introspection."""
|
|
2
|
+
|
|
3
|
+
import aiosqlite
|
|
4
|
+
import duckdb
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from sqlsaber.database import DuckDBConnection, SQLiteConnection
|
|
8
|
+
from sqlsaber.database.schema import (
|
|
9
|
+
DuckDBSchemaIntrospector,
|
|
10
|
+
SchemaManager,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.asyncio
|
|
15
|
+
async def test_duckdb_schema_manager(tmp_path):
|
|
16
|
+
"""Ensure DuckDB schema introspection surfaces tables and relationships."""
|
|
17
|
+
db_path = tmp_path / "introspection.duckdb"
|
|
18
|
+
|
|
19
|
+
conn = duckdb.connect(str(db_path))
|
|
20
|
+
try:
|
|
21
|
+
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);")
|
|
22
|
+
conn.execute(
|
|
23
|
+
"CREATE TABLE orders (id INTEGER, user_id INTEGER, FOREIGN KEY(user_id) REFERENCES users(id));"
|
|
24
|
+
)
|
|
25
|
+
conn.execute("CREATE UNIQUE INDEX idx_users_name ON users(name);")
|
|
26
|
+
finally:
|
|
27
|
+
conn.close()
|
|
28
|
+
|
|
29
|
+
db_conn = DuckDBConnection(f"duckdb:///{db_path}")
|
|
30
|
+
schema_manager = SchemaManager(db_conn)
|
|
31
|
+
|
|
32
|
+
assert isinstance(schema_manager.introspector, DuckDBSchemaIntrospector)
|
|
33
|
+
|
|
34
|
+
tables = await schema_manager.list_tables()
|
|
35
|
+
table_names = {table["full_name"] for table in tables["tables"]}
|
|
36
|
+
assert "main.users" in table_names
|
|
37
|
+
assert "main.orders" in table_names
|
|
38
|
+
|
|
39
|
+
schema_info = await schema_manager.get_schema_info()
|
|
40
|
+
users_info = schema_info["main.users"]
|
|
41
|
+
orders_info = schema_info["main.orders"]
|
|
42
|
+
|
|
43
|
+
assert "id" in users_info["columns"]
|
|
44
|
+
assert "INTEGER" in users_info["columns"]["id"]["data_type"].upper()
|
|
45
|
+
assert "id" in users_info["primary_keys"]
|
|
46
|
+
assert any(idx["name"] == "idx_users_name" for idx in users_info["indexes"])
|
|
47
|
+
|
|
48
|
+
assert any(fk["column"] == "user_id" for fk in orders_info["foreign_keys"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_duckdb_comments(tmp_path):
|
|
53
|
+
"""Test that DuckDB table and column comments are retrieved correctly."""
|
|
54
|
+
db_path = tmp_path / "comments.duckdb"
|
|
55
|
+
|
|
56
|
+
conn = duckdb.connect(str(db_path))
|
|
57
|
+
try:
|
|
58
|
+
conn.execute(
|
|
59
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price DECIMAL);"
|
|
60
|
+
)
|
|
61
|
+
conn.execute("COMMENT ON TABLE products IS 'Product catalog';")
|
|
62
|
+
conn.execute("COMMENT ON COLUMN products.id IS 'Unique product identifier';")
|
|
63
|
+
conn.execute("COMMENT ON COLUMN products.name IS 'Product name';")
|
|
64
|
+
conn.execute("COMMENT ON COLUMN products.price IS 'Product price in USD';")
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
db_conn = DuckDBConnection(f"duckdb:///{db_path}")
|
|
69
|
+
schema_manager = SchemaManager(db_conn)
|
|
70
|
+
|
|
71
|
+
# Test list_tables includes comments
|
|
72
|
+
tables = await schema_manager.list_tables()
|
|
73
|
+
products_table = next(t for t in tables["tables"] if t["table_name"] == "products")
|
|
74
|
+
assert products_table["table_comment"] == "Product catalog"
|
|
75
|
+
|
|
76
|
+
# Test get_schema_info includes comments
|
|
77
|
+
schema_info = await schema_manager.get_schema_info()
|
|
78
|
+
products_info = schema_info["main.products"]
|
|
79
|
+
|
|
80
|
+
assert products_info["comment"] == "Product catalog"
|
|
81
|
+
assert products_info["columns"]["id"]["comment"] == "Unique product identifier"
|
|
82
|
+
assert products_info["columns"]["name"]["comment"] == "Product name"
|
|
83
|
+
assert products_info["columns"]["price"]["comment"] == "Product price in USD"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_sqlite_no_comments(tmp_path):
|
|
88
|
+
"""Verify that SQLite returns None for comments since it doesn't support them."""
|
|
89
|
+
|
|
90
|
+
db_path = tmp_path / "no_comments.db"
|
|
91
|
+
|
|
92
|
+
async with aiosqlite.connect(str(db_path)) as conn:
|
|
93
|
+
await conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);")
|
|
94
|
+
await conn.commit()
|
|
95
|
+
|
|
96
|
+
db_conn = SQLiteConnection(f"sqlite:///{db_path}")
|
|
97
|
+
schema_manager = SchemaManager(db_conn)
|
|
98
|
+
|
|
99
|
+
# Test list_tables has None for comments
|
|
100
|
+
tables = await schema_manager.list_tables()
|
|
101
|
+
items_table = next(t for t in tables["tables"] if t["table_name"] == "items")
|
|
102
|
+
assert items_table["table_comment"] is None
|
|
103
|
+
|
|
104
|
+
# Test get_schema_info has None for comments
|
|
105
|
+
schema_info = await schema_manager.get_schema_info()
|
|
106
|
+
items_info = schema_info["main.items"]
|
|
107
|
+
|
|
108
|
+
assert items_info["comment"] is None
|
|
109
|
+
assert items_info["columns"]["id"]["comment"] is None
|
|
110
|
+
assert items_info["columns"]["name"]["comment"] is None
|
|
@@ -147,6 +147,39 @@ class TestSchemaDisplayMappings:
|
|
|
147
147
|
|
|
148
148
|
await db_conn.close()
|
|
149
149
|
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_schema_display_includes_comments(self, tmp_path):
|
|
152
|
+
"""Ensure schema display renders table and column comments when present."""
|
|
153
|
+
db_path = tmp_path / "test_comments.duckdb"
|
|
154
|
+
|
|
155
|
+
conn = duckdb.connect(str(db_path))
|
|
156
|
+
try:
|
|
157
|
+
conn.execute(
|
|
158
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price DECIMAL);"
|
|
159
|
+
)
|
|
160
|
+
conn.execute("COMMENT ON TABLE products IS 'Product catalog';")
|
|
161
|
+
conn.execute("COMMENT ON COLUMN products.id IS 'Unique product identifier';")
|
|
162
|
+
conn.execute("COMMENT ON COLUMN products.name IS 'Product name';")
|
|
163
|
+
finally:
|
|
164
|
+
conn.close()
|
|
165
|
+
|
|
166
|
+
db_conn = DuckDBConnection(f"duckdb:///{db_path}")
|
|
167
|
+
schema_manager = SchemaManager(db_conn)
|
|
168
|
+
schema_info = await schema_manager.get_schema_info()
|
|
169
|
+
|
|
170
|
+
string_io = StringIO()
|
|
171
|
+
console = create_console(file=string_io, width=120, legacy_windows=False)
|
|
172
|
+
display_manager = DisplayManager(console)
|
|
173
|
+
|
|
174
|
+
display_manager.show_schema_info(schema_info)
|
|
175
|
+
output = string_io.getvalue()
|
|
176
|
+
|
|
177
|
+
assert "Product catalog" in output
|
|
178
|
+
assert "Unique product identifier" in output
|
|
179
|
+
assert "Product name" in output
|
|
180
|
+
|
|
181
|
+
await db_conn.close()
|
|
182
|
+
|
|
150
183
|
@pytest.mark.asyncio
|
|
151
184
|
async def test_table_list_display_integration(self, tmp_path):
|
|
152
185
|
"""Test end-to-end table list display with type mappings."""
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""Tests for schema introspection."""
|
|
2
|
-
|
|
3
|
-
import duckdb
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from sqlsaber.database import DuckDBConnection
|
|
7
|
-
from sqlsaber.database.schema import (
|
|
8
|
-
DuckDBSchemaIntrospector,
|
|
9
|
-
SchemaManager,
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@pytest.mark.asyncio
|
|
14
|
-
async def test_duckdb_schema_manager(tmp_path):
|
|
15
|
-
"""Ensure DuckDB schema introspection surfaces tables and relationships."""
|
|
16
|
-
db_path = tmp_path / "introspection.duckdb"
|
|
17
|
-
|
|
18
|
-
conn = duckdb.connect(str(db_path))
|
|
19
|
-
try:
|
|
20
|
-
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);")
|
|
21
|
-
conn.execute(
|
|
22
|
-
"CREATE TABLE orders (id INTEGER, user_id INTEGER, FOREIGN KEY(user_id) REFERENCES users(id));"
|
|
23
|
-
)
|
|
24
|
-
conn.execute("CREATE UNIQUE INDEX idx_users_name ON users(name);")
|
|
25
|
-
finally:
|
|
26
|
-
conn.close()
|
|
27
|
-
|
|
28
|
-
db_conn = DuckDBConnection(f"duckdb:///{db_path}")
|
|
29
|
-
schema_manager = SchemaManager(db_conn)
|
|
30
|
-
|
|
31
|
-
assert isinstance(schema_manager.introspector, DuckDBSchemaIntrospector)
|
|
32
|
-
|
|
33
|
-
tables = await schema_manager.list_tables()
|
|
34
|
-
table_names = {table["full_name"] for table in tables["tables"]}
|
|
35
|
-
assert "main.users" in table_names
|
|
36
|
-
assert "main.orders" in table_names
|
|
37
|
-
|
|
38
|
-
schema_info = await schema_manager.get_schema_info()
|
|
39
|
-
users_info = schema_info["main.users"]
|
|
40
|
-
orders_info = schema_info["main.orders"]
|
|
41
|
-
|
|
42
|
-
assert "id" in users_info["columns"]
|
|
43
|
-
assert "INTEGER" in users_info["columns"]["id"]["data_type"].upper()
|
|
44
|
-
assert "id" in users_info["primary_keys"]
|
|
45
|
-
assert any(idx["name"] == "idx_users_name" for idx in users_info["indexes"])
|
|
46
|
-
|
|
47
|
-
assert any(fk["column"] == "user_id" for fk in orders_info["foreign_keys"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|