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.

Files changed (130) hide show
  1. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/PKG-INFO +1 -1
  2. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/changelog.md +17 -0
  3. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/pyproject.toml +1 -1
  4. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/display.py +19 -3
  5. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/interactive.py +4 -2
  6. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/base.py +2 -0
  7. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/duckdb.py +41 -26
  8. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/mysql.py +7 -3
  9. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/postgresql.py +7 -3
  10. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/schema.py +3 -0
  11. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/sqlite.py +18 -5
  12. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/sql_tools.py +32 -21
  13. sqlsaber-0.35.0/tests/test_database/test_schema.py +110 -0
  14. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_schema_display.py +33 -0
  15. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/uv.lock +1 -1
  16. sqlsaber-0.34.0/tests/test_database/test_schema.py +0 -47
  17. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/claude-code-review.yml +0 -0
  18. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/claude.yml +0 -0
  19. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/deploy-docs.yml +0 -0
  20. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/publish.yml +0 -0
  21. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.github/workflows/test.yml +0 -0
  22. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.gitignore +0 -0
  23. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/.python-version +0 -0
  24. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/AGENTS.md +0 -0
  25. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/CLAUDE.md +0 -0
  26. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/LICENSE +0 -0
  27. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/README.md +0 -0
  28. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/.gitignore +0 -0
  29. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/.vscode/extensions.json +0 -0
  30. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/.vscode/launch.json +0 -0
  31. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/CLAUDE.md +0 -0
  32. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/astro.config.mjs +0 -0
  33. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/package-lock.json +0 -0
  34. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/package.json +0 -0
  35. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/public/CNAME +0 -0
  36. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/public/favicon.svg +0 -0
  37. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/assets/sqlsaber.gif +0 -0
  38. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/authentication.mdx +0 -0
  39. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/database-setup.mdx +0 -0
  40. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/getting-started.mdx +0 -0
  41. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/memory.mdx +0 -0
  42. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/models.mdx +0 -0
  43. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/queries.mdx +0 -0
  44. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/guides/threads.md +0 -0
  45. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/index.mdx +0 -0
  46. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/installation.mdx +0 -0
  47. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content/docs/reference/commands.md +0 -0
  48. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/content.config.ts +0 -0
  49. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/src/styles/global.css +0 -0
  50. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/docs/tsconfig.json +0 -0
  51. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/legislators.db +0 -0
  52. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/pytest.ini +0 -0
  53. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/sqlsaber.gif +0 -0
  54. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/sqlsaber.svg +0 -0
  55. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/__init__.py +0 -0
  56. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/__main__.py +0 -0
  57. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/agents/__init__.py +0 -0
  58. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/agents/base.py +0 -0
  59. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/agents/pydantic_ai_agent.py +0 -0
  60. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/__init__.py +0 -0
  61. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/auth_setup.py +0 -0
  62. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/db_setup.py +0 -0
  63. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/model_selection.py +0 -0
  64. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/application/prompts.py +0 -0
  65. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/__init__.py +0 -0
  66. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/auth.py +0 -0
  67. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/commands.py +0 -0
  68. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/completers.py +0 -0
  69. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/database.py +0 -0
  70. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/memory.py +0 -0
  71. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/models.py +0 -0
  72. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/onboarding.py +0 -0
  73. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/streaming.py +0 -0
  74. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/theme.py +0 -0
  75. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/cli/threads.py +0 -0
  76. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/__init__.py +0 -0
  77. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/api_keys.py +0 -0
  78. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/auth.py +0 -0
  79. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/database.py +0 -0
  80. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/logging.py +0 -0
  81. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/oauth_flow.py +0 -0
  82. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/oauth_tokens.py +0 -0
  83. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/providers.py +0 -0
  84. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/config/settings.py +0 -0
  85. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/__init__.py +0 -0
  86. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/csv.py +0 -0
  87. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/database/resolver.py +0 -0
  88. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/memory/__init__.py +0 -0
  89. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/memory/manager.py +0 -0
  90. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/memory/storage.py +0 -0
  91. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/__init__.py +0 -0
  92. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/claude.py +0 -0
  93. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/memory.py +0 -0
  94. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/prompts/openai.py +0 -0
  95. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/theme/__init__.py +0 -0
  96. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/theme/manager.py +0 -0
  97. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/threads/__init__.py +0 -0
  98. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/threads/storage.py +0 -0
  99. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/__init__.py +0 -0
  100. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/base.py +0 -0
  101. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/registry.py +0 -0
  102. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/src/sqlsaber/tools/sql_guard.py +0 -0
  103. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/__init__.py +0 -0
  104. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/conftest.py +0 -0
  105. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_application/test_auth_setup.py +0 -0
  106. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/__init__.py +0 -0
  107. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/test_auth_reset.py +0 -0
  108. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/test_commands.py +0 -0
  109. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_cli/test_threads.py +0 -0
  110. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/__init__.py +0 -0
  111. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_database.py +0 -0
  112. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_oauth.py +0 -0
  113. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_providers.py +0 -0
  114. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_config/test_settings.py +0 -0
  115. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/__init__.py +0 -0
  116. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_connection.py +0 -0
  117. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_csv_connection.py +0 -0
  118. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_csv_module.py +0 -0
  119. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_duckdb_module.py +0 -0
  120. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_postgresql_module.py +0 -0
  121. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_sqlite_module.py +0 -0
  122. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database/test_timeout.py +0 -0
  123. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_database_resolver.py +0 -0
  124. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_theme/test_manager.py +0 -0
  125. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_threads_storage.py +0 -0
  126. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/__init__.py +0 -0
  127. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_base.py +0 -0
  128. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_registry.py +0 -0
  129. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_sql_guard.py +0 -0
  130. {sqlsaber-0.34.0 → sqlsaber-0.35.0}/tests/test_tools/test_sql_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlsaber
3
- Version: 0.34.0
3
+ Version: 0.35.0
4
4
  Summary: SQLsaber - Open-source agentic SQL assistant
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlsaber"
3
- version = "0.34.0"
3
+ version = "0.35.0"
4
4
  description = "SQLsaber - Open-source agentic SQL assistant"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- col_table.add_row(
429
- col_name, col_info.get("type", ""), nullable, default
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.strip()[1:].strip())
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("(table_schema LIKE ? AND table_name LIKE ?)")
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
- FROM information_schema.tables
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
- FROM information_schema.columns
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
- FROM information_schema.tables
314
- WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')
315
- ORDER BY table_schema, table_name;
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
- formatted_info[table_name] = {
99
- "columns": {
100
- col_name: {
101
- "type": col_info["data_type"],
102
- "nullable": col_info["nullable"],
103
- "default": col_info["default"],
104
- }
105
- for col_name, col_info in table_info["columns"].items()
106
- },
107
- "primary_keys": table_info["primary_keys"],
108
- "foreign_keys": [
109
- f"{fk['column']} -> {fk['references']['table']}.{fk['references']['column']}"
110
- for fk in table_info["foreign_keys"]
111
- ],
112
- "indexes": [
113
- f"{idx['name']} ({', '.join(idx['columns'])})"
114
- + (" UNIQUE" if idx["unique"] else "")
115
- + (f" [{idx['type']}]" if idx["type"] else "")
116
- for idx in table_info["indexes"]
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."""
@@ -1922,7 +1922,7 @@ wheels = [
1922
1922
 
1923
1923
  [[package]]
1924
1924
  name = "sqlsaber"
1925
- version = "0.34.0"
1925
+ version = "0.35.0"
1926
1926
  source = { editable = "." }
1927
1927
  dependencies = [
1928
1928
  { name = "aiomysql" },
@@ -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