sqlsaber 0.23.0__py3-none-any.whl → 0.25.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sqlsaber might be problematic. Click here for more details.
- sqlsaber/agents/base.py +4 -1
- sqlsaber/agents/pydantic_ai_agent.py +4 -1
- sqlsaber/cli/commands.py +19 -11
- sqlsaber/cli/database.py +17 -6
- sqlsaber/cli/display.py +49 -19
- sqlsaber/cli/interactive.py +6 -1
- sqlsaber/cli/threads.py +41 -18
- sqlsaber/config/database.py +3 -1
- sqlsaber/database/connection.py +123 -99
- sqlsaber/database/resolver.py +7 -3
- sqlsaber/database/schema.py +377 -1
- sqlsaber/tools/sql_tools.py +6 -0
- {sqlsaber-0.23.0.dist-info → sqlsaber-0.25.0.dist-info}/METADATA +4 -3
- {sqlsaber-0.23.0.dist-info → sqlsaber-0.25.0.dist-info}/RECORD +17 -17
- {sqlsaber-0.23.0.dist-info → sqlsaber-0.25.0.dist-info}/WHEEL +0 -0
- {sqlsaber-0.23.0.dist-info → sqlsaber-0.25.0.dist-info}/entry_points.txt +0 -0
- {sqlsaber-0.23.0.dist-info → sqlsaber-0.25.0.dist-info}/licenses/LICENSE +0 -0
sqlsaber/database/schema.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
"""Database schema introspection utilities."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
from abc import ABC, abstractmethod
|
|
4
5
|
from typing import Any, TypedDict
|
|
5
6
|
|
|
6
7
|
import aiosqlite
|
|
8
|
+
import duckdb
|
|
7
9
|
|
|
8
10
|
from sqlsaber.database.connection import (
|
|
9
11
|
BaseDatabaseConnection,
|
|
10
12
|
CSVConnection,
|
|
13
|
+
DuckDBConnection,
|
|
11
14
|
MySQLConnection,
|
|
12
15
|
PostgreSQLConnection,
|
|
13
16
|
SQLiteConnection,
|
|
@@ -32,6 +35,15 @@ class ForeignKeyInfo(TypedDict):
|
|
|
32
35
|
references: dict[str, str] # {"table": "schema.table", "column": "column_name"}
|
|
33
36
|
|
|
34
37
|
|
|
38
|
+
class IndexInfo(TypedDict):
|
|
39
|
+
"""Type definition for index information."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
columns: list[str] # ordered
|
|
43
|
+
unique: bool
|
|
44
|
+
type: str | None # btree, gin, FULLTEXT, etc. None if unknown
|
|
45
|
+
|
|
46
|
+
|
|
35
47
|
class SchemaInfo(TypedDict):
|
|
36
48
|
"""Type definition for schema information."""
|
|
37
49
|
|
|
@@ -41,6 +53,7 @@ class SchemaInfo(TypedDict):
|
|
|
41
53
|
columns: dict[str, ColumnInfo]
|
|
42
54
|
primary_keys: list[str]
|
|
43
55
|
foreign_keys: list[ForeignKeyInfo]
|
|
56
|
+
indexes: list[IndexInfo]
|
|
44
57
|
|
|
45
58
|
|
|
46
59
|
class BaseSchemaIntrospector(ABC):
|
|
@@ -68,6 +81,11 @@ class BaseSchemaIntrospector(ABC):
|
|
|
68
81
|
"""Get primary keys information for the specific database type."""
|
|
69
82
|
pass
|
|
70
83
|
|
|
84
|
+
@abstractmethod
|
|
85
|
+
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
86
|
+
"""Get indexes information for the specific database type."""
|
|
87
|
+
pass
|
|
88
|
+
|
|
71
89
|
@abstractmethod
|
|
72
90
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
73
91
|
"""Get list of tables with basic information."""
|
|
@@ -209,6 +227,43 @@ class PostgreSQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
209
227
|
"""
|
|
210
228
|
return await conn.fetch(pk_query)
|
|
211
229
|
|
|
230
|
+
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
231
|
+
"""Get indexes information for PostgreSQL."""
|
|
232
|
+
if not tables:
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
pool = await connection.get_pool()
|
|
236
|
+
async with pool.acquire() as conn:
|
|
237
|
+
# Build proper table filters
|
|
238
|
+
idx_table_filters = []
|
|
239
|
+
for table in tables:
|
|
240
|
+
idx_table_filters.append(
|
|
241
|
+
f"(ns.nspname = '{table['table_schema']}' AND t.relname = '{table['table_name']}')"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
idx_query = f"""
|
|
245
|
+
SELECT
|
|
246
|
+
ns.nspname AS table_schema,
|
|
247
|
+
t.relname AS table_name,
|
|
248
|
+
i.relname AS index_name,
|
|
249
|
+
ix.indisunique AS is_unique,
|
|
250
|
+
am.amname AS index_type,
|
|
251
|
+
array_agg(a.attname ORDER BY ord.ordinality) AS column_names
|
|
252
|
+
FROM pg_class t
|
|
253
|
+
JOIN pg_namespace ns ON ns.oid = t.relnamespace
|
|
254
|
+
JOIN pg_index ix ON ix.indrelid = t.oid
|
|
255
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
256
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
257
|
+
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ordinality)
|
|
258
|
+
ON TRUE
|
|
259
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum
|
|
260
|
+
WHERE ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
261
|
+
AND ({" OR ".join(idx_table_filters)})
|
|
262
|
+
GROUP BY table_schema, table_name, index_name, is_unique, index_type
|
|
263
|
+
ORDER BY table_schema, table_name, index_name;
|
|
264
|
+
"""
|
|
265
|
+
return await conn.fetch(idx_query)
|
|
266
|
+
|
|
212
267
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
213
268
|
"""Get list of tables with basic information for PostgreSQL."""
|
|
214
269
|
pool = await connection.get_pool()
|
|
@@ -379,6 +434,37 @@ class MySQLSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
379
434
|
await cursor.execute(pk_query)
|
|
380
435
|
return await cursor.fetchall()
|
|
381
436
|
|
|
437
|
+
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
438
|
+
"""Get indexes information for MySQL."""
|
|
439
|
+
if not tables:
|
|
440
|
+
return []
|
|
441
|
+
|
|
442
|
+
pool = await connection.get_pool()
|
|
443
|
+
async with pool.acquire() as conn:
|
|
444
|
+
async with conn.cursor() as cursor:
|
|
445
|
+
# Build proper table filters
|
|
446
|
+
idx_table_filters = []
|
|
447
|
+
for table in tables:
|
|
448
|
+
idx_table_filters.append(
|
|
449
|
+
f"(TABLE_SCHEMA = '{table['table_schema']}' AND TABLE_NAME = '{table['table_name']}')"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
idx_query = f"""
|
|
453
|
+
SELECT
|
|
454
|
+
TABLE_SCHEMA AS table_schema,
|
|
455
|
+
TABLE_NAME AS table_name,
|
|
456
|
+
INDEX_NAME AS index_name,
|
|
457
|
+
(NON_UNIQUE = 0) AS is_unique,
|
|
458
|
+
INDEX_TYPE AS index_type,
|
|
459
|
+
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS column_names
|
|
460
|
+
FROM INFORMATION_SCHEMA.STATISTICS
|
|
461
|
+
WHERE ({" OR ".join(idx_table_filters)})
|
|
462
|
+
GROUP BY table_schema, table_name, index_name, is_unique, index_type
|
|
463
|
+
ORDER BY table_schema, table_name, index_name;
|
|
464
|
+
"""
|
|
465
|
+
await cursor.execute(idx_query)
|
|
466
|
+
return await cursor.fetchall()
|
|
467
|
+
|
|
382
468
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
383
469
|
"""Get list of tables with basic information for MySQL."""
|
|
384
470
|
pool = await connection.get_pool()
|
|
@@ -531,6 +617,47 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
531
617
|
|
|
532
618
|
return primary_keys
|
|
533
619
|
|
|
620
|
+
async def get_indexes_info(self, connection, tables: list) -> list:
|
|
621
|
+
"""Get indexes information for SQLite."""
|
|
622
|
+
if not tables:
|
|
623
|
+
return []
|
|
624
|
+
|
|
625
|
+
indexes = []
|
|
626
|
+
for table in tables:
|
|
627
|
+
table_name = table["table_name"]
|
|
628
|
+
|
|
629
|
+
# Get index list using PRAGMA
|
|
630
|
+
pragma_query = f"PRAGMA index_list({table_name})"
|
|
631
|
+
table_indexes = await self._execute_query(connection, pragma_query)
|
|
632
|
+
|
|
633
|
+
for idx in table_indexes:
|
|
634
|
+
idx_name = idx["name"]
|
|
635
|
+
unique = bool(idx["unique"])
|
|
636
|
+
|
|
637
|
+
# Skip auto-generated primary key indexes
|
|
638
|
+
if idx_name.startswith("sqlite_autoindex_"):
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
# Get index columns using PRAGMA
|
|
642
|
+
pragma_info_query = f"PRAGMA index_info({idx_name})"
|
|
643
|
+
idx_cols = await self._execute_query(connection, pragma_info_query)
|
|
644
|
+
columns = [
|
|
645
|
+
c["name"] for c in sorted(idx_cols, key=lambda r: r["seqno"])
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
indexes.append(
|
|
649
|
+
{
|
|
650
|
+
"table_schema": "main",
|
|
651
|
+
"table_name": table_name,
|
|
652
|
+
"index_name": idx_name,
|
|
653
|
+
"is_unique": unique,
|
|
654
|
+
"index_type": None, # SQLite only has B-tree currently
|
|
655
|
+
"column_names": columns,
|
|
656
|
+
}
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
return indexes
|
|
660
|
+
|
|
534
661
|
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
535
662
|
"""Get list of tables with basic information for SQLite."""
|
|
536
663
|
# Get table names without row counts for better performance
|
|
@@ -558,6 +685,225 @@ class SQLiteSchemaIntrospector(BaseSchemaIntrospector):
|
|
|
558
685
|
]
|
|
559
686
|
|
|
560
687
|
|
|
688
|
+
class DuckDBSchemaIntrospector(BaseSchemaIntrospector):
|
|
689
|
+
"""DuckDB-specific schema introspection."""
|
|
690
|
+
|
|
691
|
+
async def _execute_query(
|
|
692
|
+
self,
|
|
693
|
+
connection: DuckDBConnection | CSVConnection,
|
|
694
|
+
query: str,
|
|
695
|
+
params: tuple[Any, ...] = (),
|
|
696
|
+
) -> list[dict[str, Any]]:
|
|
697
|
+
"""Run a DuckDB query on a thread and return list of dictionaries."""
|
|
698
|
+
|
|
699
|
+
params_tuple = tuple(params)
|
|
700
|
+
|
|
701
|
+
def fetch_rows(conn: duckdb.DuckDBPyConnection) -> list[dict[str, Any]]:
|
|
702
|
+
cursor = conn.execute(query, params_tuple)
|
|
703
|
+
if cursor.description is None:
|
|
704
|
+
return []
|
|
705
|
+
|
|
706
|
+
columns = [col[0] for col in cursor.description]
|
|
707
|
+
rows = conn.fetchall()
|
|
708
|
+
return [dict(zip(columns, row)) for row in rows]
|
|
709
|
+
|
|
710
|
+
if isinstance(connection, CSVConnection):
|
|
711
|
+
return await connection.execute_query(query, *params_tuple)
|
|
712
|
+
|
|
713
|
+
def run_query() -> list[dict[str, Any]]:
|
|
714
|
+
conn = duckdb.connect(connection.database_path)
|
|
715
|
+
try:
|
|
716
|
+
return fetch_rows(conn)
|
|
717
|
+
finally:
|
|
718
|
+
conn.close()
|
|
719
|
+
|
|
720
|
+
return await asyncio.to_thread(run_query)
|
|
721
|
+
|
|
722
|
+
async def get_tables_info(
|
|
723
|
+
self, connection, table_pattern: str | None = None
|
|
724
|
+
) -> list[dict[str, Any]]:
|
|
725
|
+
"""Get tables information for DuckDB."""
|
|
726
|
+
where_conditions = [
|
|
727
|
+
"table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')"
|
|
728
|
+
]
|
|
729
|
+
params: list[Any] = []
|
|
730
|
+
|
|
731
|
+
if table_pattern:
|
|
732
|
+
if "." in table_pattern:
|
|
733
|
+
schema_pattern, table_name_pattern = table_pattern.split(".", 1)
|
|
734
|
+
where_conditions.append(
|
|
735
|
+
"(table_schema LIKE ? AND table_name LIKE ?)"
|
|
736
|
+
)
|
|
737
|
+
params.extend([schema_pattern, table_name_pattern])
|
|
738
|
+
else:
|
|
739
|
+
where_conditions.append(
|
|
740
|
+
"(table_name LIKE ? OR table_schema || '.' || table_name LIKE ?)"
|
|
741
|
+
)
|
|
742
|
+
params.extend([table_pattern, table_pattern])
|
|
743
|
+
|
|
744
|
+
query = f"""
|
|
745
|
+
SELECT
|
|
746
|
+
table_schema,
|
|
747
|
+
table_name,
|
|
748
|
+
table_type
|
|
749
|
+
FROM information_schema.tables
|
|
750
|
+
WHERE {" AND ".join(where_conditions)}
|
|
751
|
+
ORDER BY table_schema, table_name;
|
|
752
|
+
"""
|
|
753
|
+
|
|
754
|
+
return await self._execute_query(connection, query, tuple(params))
|
|
755
|
+
|
|
756
|
+
async def get_columns_info(self, connection, tables: list) -> list[dict[str, Any]]:
|
|
757
|
+
"""Get columns information for DuckDB."""
|
|
758
|
+
if not tables:
|
|
759
|
+
return []
|
|
760
|
+
|
|
761
|
+
table_filters = []
|
|
762
|
+
for table in tables:
|
|
763
|
+
table_filters.append(
|
|
764
|
+
"(table_schema = ? AND table_name = ?)"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
params: list[Any] = []
|
|
768
|
+
for table in tables:
|
|
769
|
+
params.extend([table["table_schema"], table["table_name"]])
|
|
770
|
+
|
|
771
|
+
query = f"""
|
|
772
|
+
SELECT
|
|
773
|
+
table_schema,
|
|
774
|
+
table_name,
|
|
775
|
+
column_name,
|
|
776
|
+
data_type,
|
|
777
|
+
is_nullable,
|
|
778
|
+
column_default,
|
|
779
|
+
character_maximum_length,
|
|
780
|
+
numeric_precision,
|
|
781
|
+
numeric_scale
|
|
782
|
+
FROM information_schema.columns
|
|
783
|
+
WHERE {" OR ".join(table_filters)}
|
|
784
|
+
ORDER BY table_schema, table_name, ordinal_position;
|
|
785
|
+
"""
|
|
786
|
+
|
|
787
|
+
return await self._execute_query(connection, query, tuple(params))
|
|
788
|
+
|
|
789
|
+
async def get_foreign_keys_info(self, connection, tables: list) -> list[dict[str, Any]]:
|
|
790
|
+
"""Get foreign keys information for DuckDB."""
|
|
791
|
+
if not tables:
|
|
792
|
+
return []
|
|
793
|
+
|
|
794
|
+
table_filters = []
|
|
795
|
+
params: list[Any] = []
|
|
796
|
+
for table in tables:
|
|
797
|
+
table_filters.append("(kcu.table_schema = ? AND kcu.table_name = ?)")
|
|
798
|
+
params.extend([table["table_schema"], table["table_name"]])
|
|
799
|
+
|
|
800
|
+
query = f"""
|
|
801
|
+
SELECT
|
|
802
|
+
kcu.table_schema,
|
|
803
|
+
kcu.table_name,
|
|
804
|
+
kcu.column_name,
|
|
805
|
+
ccu.table_schema AS foreign_table_schema,
|
|
806
|
+
ccu.table_name AS foreign_table_name,
|
|
807
|
+
ccu.column_name AS foreign_column_name
|
|
808
|
+
FROM information_schema.referential_constraints AS rc
|
|
809
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
810
|
+
ON rc.constraint_schema = kcu.constraint_schema
|
|
811
|
+
AND rc.constraint_name = kcu.constraint_name
|
|
812
|
+
JOIN information_schema.key_column_usage AS ccu
|
|
813
|
+
ON rc.unique_constraint_schema = ccu.constraint_schema
|
|
814
|
+
AND rc.unique_constraint_name = ccu.constraint_name
|
|
815
|
+
AND ccu.ordinal_position = kcu.position_in_unique_constraint
|
|
816
|
+
WHERE {" OR ".join(table_filters)}
|
|
817
|
+
ORDER BY kcu.table_schema, kcu.table_name, kcu.ordinal_position;
|
|
818
|
+
"""
|
|
819
|
+
|
|
820
|
+
return await self._execute_query(connection, query, tuple(params))
|
|
821
|
+
|
|
822
|
+
async def get_primary_keys_info(self, connection, tables: list) -> list[dict[str, Any]]:
|
|
823
|
+
"""Get primary keys information for DuckDB."""
|
|
824
|
+
if not tables:
|
|
825
|
+
return []
|
|
826
|
+
|
|
827
|
+
table_filters = []
|
|
828
|
+
params: list[Any] = []
|
|
829
|
+
for table in tables:
|
|
830
|
+
table_filters.append("(tc.table_schema = ? AND tc.table_name = ?)")
|
|
831
|
+
params.extend([table["table_schema"], table["table_name"]])
|
|
832
|
+
|
|
833
|
+
query = f"""
|
|
834
|
+
SELECT
|
|
835
|
+
tc.table_schema,
|
|
836
|
+
tc.table_name,
|
|
837
|
+
kcu.column_name
|
|
838
|
+
FROM information_schema.table_constraints AS tc
|
|
839
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
840
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
841
|
+
AND tc.constraint_schema = kcu.constraint_schema
|
|
842
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
843
|
+
AND ({" OR ".join(table_filters)})
|
|
844
|
+
ORDER BY tc.table_schema, tc.table_name, kcu.ordinal_position;
|
|
845
|
+
"""
|
|
846
|
+
|
|
847
|
+
return await self._execute_query(connection, query, tuple(params))
|
|
848
|
+
|
|
849
|
+
async def get_indexes_info(self, connection, tables: list) -> list[dict[str, Any]]:
|
|
850
|
+
"""Get indexes information for DuckDB."""
|
|
851
|
+
if not tables:
|
|
852
|
+
return []
|
|
853
|
+
|
|
854
|
+
indexes: list[dict[str, Any]] = []
|
|
855
|
+
for table in tables:
|
|
856
|
+
schema = table["table_schema"]
|
|
857
|
+
table_name = table["table_name"]
|
|
858
|
+
query = """
|
|
859
|
+
SELECT
|
|
860
|
+
schema_name,
|
|
861
|
+
table_name,
|
|
862
|
+
index_name,
|
|
863
|
+
sql
|
|
864
|
+
FROM duckdb_indexes()
|
|
865
|
+
WHERE schema_name = ? AND table_name = ?;
|
|
866
|
+
"""
|
|
867
|
+
rows = await self._execute_query(connection, query, (schema, table_name))
|
|
868
|
+
|
|
869
|
+
for row in rows:
|
|
870
|
+
sql_text = (row.get("sql") or "").strip()
|
|
871
|
+
upper_sql = sql_text.upper()
|
|
872
|
+
unique = "UNIQUE" in upper_sql.split("(")[0]
|
|
873
|
+
|
|
874
|
+
columns: list[str] = []
|
|
875
|
+
if "(" in sql_text and ")" in sql_text:
|
|
876
|
+
column_section = sql_text[sql_text.find("(") + 1 : sql_text.rfind(")")]
|
|
877
|
+
columns = [col.strip().strip('"') for col in column_section.split(",") if col.strip()]
|
|
878
|
+
|
|
879
|
+
indexes.append(
|
|
880
|
+
{
|
|
881
|
+
"table_schema": row.get("schema_name") or schema or "main",
|
|
882
|
+
"table_name": row.get("table_name") or table_name,
|
|
883
|
+
"index_name": row.get("index_name"),
|
|
884
|
+
"is_unique": unique,
|
|
885
|
+
"index_type": None,
|
|
886
|
+
"column_names": columns,
|
|
887
|
+
}
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
return indexes
|
|
891
|
+
|
|
892
|
+
async def list_tables_info(self, connection) -> list[dict[str, Any]]:
|
|
893
|
+
"""Get list of tables with basic information for DuckDB."""
|
|
894
|
+
query = """
|
|
895
|
+
SELECT
|
|
896
|
+
table_schema,
|
|
897
|
+
table_name,
|
|
898
|
+
table_type
|
|
899
|
+
FROM information_schema.tables
|
|
900
|
+
WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'duckdb_catalog')
|
|
901
|
+
ORDER BY table_schema, table_name;
|
|
902
|
+
"""
|
|
903
|
+
|
|
904
|
+
return await self._execute_query(connection, query)
|
|
905
|
+
|
|
906
|
+
|
|
561
907
|
class SchemaManager:
|
|
562
908
|
"""Manages database schema introspection."""
|
|
563
909
|
|
|
@@ -569,8 +915,10 @@ class SchemaManager:
|
|
|
569
915
|
self.introspector = PostgreSQLSchemaIntrospector()
|
|
570
916
|
elif isinstance(db_connection, MySQLConnection):
|
|
571
917
|
self.introspector = MySQLSchemaIntrospector()
|
|
572
|
-
elif isinstance(db_connection,
|
|
918
|
+
elif isinstance(db_connection, SQLiteConnection):
|
|
573
919
|
self.introspector = SQLiteSchemaIntrospector()
|
|
920
|
+
elif isinstance(db_connection, (DuckDBConnection, CSVConnection)):
|
|
921
|
+
self.introspector = DuckDBSchemaIntrospector()
|
|
574
922
|
else:
|
|
575
923
|
raise ValueError(
|
|
576
924
|
f"Unsupported database connection type: {type(db_connection)}"
|
|
@@ -589,12 +937,14 @@ class SchemaManager:
|
|
|
589
937
|
columns = await self.introspector.get_columns_info(self.db, tables)
|
|
590
938
|
foreign_keys = await self.introspector.get_foreign_keys_info(self.db, tables)
|
|
591
939
|
primary_keys = await self.introspector.get_primary_keys_info(self.db, tables)
|
|
940
|
+
indexes = await self.introspector.get_indexes_info(self.db, tables)
|
|
592
941
|
|
|
593
942
|
# Build schema structure
|
|
594
943
|
schema_info = self._build_table_structure(tables)
|
|
595
944
|
self._add_columns_to_schema(schema_info, columns)
|
|
596
945
|
self._add_primary_keys_to_schema(schema_info, primary_keys)
|
|
597
946
|
self._add_foreign_keys_to_schema(schema_info, foreign_keys)
|
|
947
|
+
self._add_indexes_to_schema(schema_info, indexes)
|
|
598
948
|
|
|
599
949
|
return schema_info
|
|
600
950
|
|
|
@@ -613,6 +963,7 @@ class SchemaManager:
|
|
|
613
963
|
"columns": {},
|
|
614
964
|
"primary_keys": [],
|
|
615
965
|
"foreign_keys": [],
|
|
966
|
+
"indexes": [],
|
|
616
967
|
}
|
|
617
968
|
return schema_info
|
|
618
969
|
|
|
@@ -666,6 +1017,31 @@ class SchemaManager:
|
|
|
666
1017
|
}
|
|
667
1018
|
)
|
|
668
1019
|
|
|
1020
|
+
def _add_indexes_to_schema(
|
|
1021
|
+
self, schema_info: dict[str, dict], indexes: list
|
|
1022
|
+
) -> None:
|
|
1023
|
+
"""Add index information to schema."""
|
|
1024
|
+
for idx in indexes:
|
|
1025
|
+
full_name = f"{idx['table_schema']}.{idx['table_name']}"
|
|
1026
|
+
if full_name in schema_info:
|
|
1027
|
+
# Handle different column name formats from different databases
|
|
1028
|
+
if isinstance(idx["column_names"], list):
|
|
1029
|
+
columns = idx["column_names"]
|
|
1030
|
+
else:
|
|
1031
|
+
# MySQL returns comma-separated string
|
|
1032
|
+
columns = (
|
|
1033
|
+
idx["column_names"].split(",") if idx["column_names"] else []
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
schema_info[full_name]["indexes"].append(
|
|
1037
|
+
{
|
|
1038
|
+
"name": idx["index_name"],
|
|
1039
|
+
"columns": columns,
|
|
1040
|
+
"unique": idx["is_unique"],
|
|
1041
|
+
"type": idx.get("index_type"),
|
|
1042
|
+
}
|
|
1043
|
+
)
|
|
1044
|
+
|
|
669
1045
|
async def list_tables(self) -> dict[str, Any]:
|
|
670
1046
|
"""Get a list of all tables with basic information."""
|
|
671
1047
|
tables = await self.introspector.list_tables_info(self.db)
|
sqlsaber/tools/sql_tools.py
CHANGED
|
@@ -138,6 +138,12 @@ class IntrospectSchemaTool(SQLTool):
|
|
|
138
138
|
f"{fk['column']} -> {fk['references']['table']}.{fk['references']['column']}"
|
|
139
139
|
for fk in table_info["foreign_keys"]
|
|
140
140
|
],
|
|
141
|
+
"indexes": [
|
|
142
|
+
f"{idx['name']} ({', '.join(idx['columns'])})"
|
|
143
|
+
+ (" UNIQUE" if idx["unique"] else "")
|
|
144
|
+
+ (f" [{idx['type']}]" if idx["type"] else "")
|
|
145
|
+
for idx in table_info["indexes"]
|
|
146
|
+
],
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
return json.dumps(formatted_info)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlsaber
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.25.0
|
|
4
4
|
Summary: SQLsaber - Open-source agentic SQL assistant
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -8,10 +8,10 @@ Requires-Dist: aiomysql>=0.2.0
|
|
|
8
8
|
Requires-Dist: aiosqlite>=0.21.0
|
|
9
9
|
Requires-Dist: asyncpg>=0.30.0
|
|
10
10
|
Requires-Dist: cyclopts>=3.22.1
|
|
11
|
+
Requires-Dist: duckdb>=0.9.2
|
|
11
12
|
Requires-Dist: fastmcp>=2.9.0
|
|
12
13
|
Requires-Dist: httpx>=0.28.1
|
|
13
14
|
Requires-Dist: keyring>=25.6.0
|
|
14
|
-
Requires-Dist: pandas>=2.0.0
|
|
15
15
|
Requires-Dist: platformdirs>=4.0.0
|
|
16
16
|
Requires-Dist: prompt-toolkit>3.0.51
|
|
17
17
|
Requires-Dist: pydantic-ai
|
|
@@ -58,7 +58,7 @@ Ask your questions in natural language and `sqlsaber` will gather the right cont
|
|
|
58
58
|
- 🧠 Memory management
|
|
59
59
|
- 💬 Interactive REPL mode
|
|
60
60
|
- 🧵 Conversation threads (store, display, and resume conversations)
|
|
61
|
-
- 🗄️ Support for PostgreSQL, SQLite, and
|
|
61
|
+
- 🗄️ Support for PostgreSQL, MySQL, SQLite, and DuckDB
|
|
62
62
|
- 🔌 MCP (Model Context Protocol) server support
|
|
63
63
|
- 🎨 Beautiful formatted output
|
|
64
64
|
|
|
@@ -170,6 +170,7 @@ saber -d mydb "count all orders"
|
|
|
170
170
|
|
|
171
171
|
# You can also pass a connection string
|
|
172
172
|
saber -d "postgresql://user:password@localhost:5432/mydb" "count all orders"
|
|
173
|
+
saber -d "duckdb:///path/to/data.duckdb" "top customers"
|
|
173
174
|
```
|
|
174
175
|
|
|
175
176
|
## Examples
|
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
sqlsaber/__init__.py,sha256=HjS8ULtP4MGpnTL7njVY45NKV9Fi4e_yeYuY-hyXWQc,73
|
|
2
2
|
sqlsaber/__main__.py,sha256=RIHxWeWh2QvLfah-2OkhI5IJxojWfy4fXpMnVEJYvxw,78
|
|
3
3
|
sqlsaber/agents/__init__.py,sha256=i_MI2eWMQaVzGikKU71FPCmSQxNDKq36Imq1PrYoIPU,130
|
|
4
|
-
sqlsaber/agents/base.py,sha256=
|
|
4
|
+
sqlsaber/agents/base.py,sha256=EAuoj3vpWNqksudMd2lL1Fmx68Y91qNX6NyK1RjQ4-g,2679
|
|
5
5
|
sqlsaber/agents/mcp.py,sha256=GcJTx7YDYH6aaxIADEIxSgcWAdWakUx395JIzVnf17U,768
|
|
6
|
-
sqlsaber/agents/pydantic_ai_agent.py,sha256=
|
|
6
|
+
sqlsaber/agents/pydantic_ai_agent.py,sha256=qn-DnTGcdUzSEn9xBWwGhgtifYxZ_NEo8XPePnl1StE,7154
|
|
7
7
|
sqlsaber/cli/__init__.py,sha256=qVSLVJLLJYzoC6aj6y9MFrzZvAwc4_OgxU9DlkQnZ4M,86
|
|
8
8
|
sqlsaber/cli/auth.py,sha256=jTsRgbmlGPlASSuIKmdjjwfqtKvjfKd_cTYxX0-QqaQ,7400
|
|
9
|
-
sqlsaber/cli/commands.py,sha256=
|
|
9
|
+
sqlsaber/cli/commands.py,sha256=NyBDr5qEnCOZrHEMGcEpHLXEWdlzEQW3D61NIrPi2fQ,8727
|
|
10
10
|
sqlsaber/cli/completers.py,sha256=HsUPjaZweLSeYCWkAcgMl8FylQ1xjWBWYTEL_9F6xfU,6430
|
|
11
|
-
sqlsaber/cli/database.py,sha256=
|
|
12
|
-
sqlsaber/cli/display.py,sha256=
|
|
13
|
-
sqlsaber/cli/interactive.py,sha256=
|
|
11
|
+
sqlsaber/cli/database.py,sha256=93etjqiYAfH08jBe_OJpLMNKiu3H81G8O7CMB31MIIc,13424
|
|
12
|
+
sqlsaber/cli/display.py,sha256=XuKiTWUw5k0U0P_f1K7zhDWX5KTO2DQVG0Q0XU9VEhs,16334
|
|
13
|
+
sqlsaber/cli/interactive.py,sha256=lVOtONBeAmZxWdfkvdoVoX4POs_-C1YVs0jPxY9MoZs,13288
|
|
14
14
|
sqlsaber/cli/memory.py,sha256=OufHFJFwV0_GGn7LvKRTJikkWhV1IwNIUDOxFPHXOaQ,7794
|
|
15
15
|
sqlsaber/cli/models.py,sha256=ZewtwGQwhd9b-yxBAPKePolvI1qQG-EkmeWAGMqtWNQ,8986
|
|
16
16
|
sqlsaber/cli/streaming.py,sha256=Eo5CNUgDGY1WYP90jwDA2aY7RefN-TfcStA6NyjUQTY,7076
|
|
17
|
-
sqlsaber/cli/threads.py,sha256=
|
|
17
|
+
sqlsaber/cli/threads.py,sha256=ufDABlqndVJKd5COgSokcFRIKTgsGqXdHV84DVVm7MA,12743
|
|
18
18
|
sqlsaber/config/__init__.py,sha256=olwC45k8Nc61yK0WmPUk7XHdbsZH9HuUAbwnmKe3IgA,100
|
|
19
19
|
sqlsaber/config/api_keys.py,sha256=RqWQCko1tY7sES7YOlexgBH5Hd5ne_kGXHdBDNqcV2U,3649
|
|
20
20
|
sqlsaber/config/auth.py,sha256=b5qB2h1doXyO9Bn8z0CcL8LAR2jF431gGXBGKLgTmtQ,2756
|
|
21
|
-
sqlsaber/config/database.py,sha256=
|
|
21
|
+
sqlsaber/config/database.py,sha256=Yec6_0wdzq-ADblMNnbgvouYCimYOY_DWHT9oweaISc,11449
|
|
22
22
|
sqlsaber/config/oauth_flow.py,sha256=A3bSXaBLzuAfXV2ZPA94m9NV33c2MyL6M4ii9oEkswQ,10291
|
|
23
23
|
sqlsaber/config/oauth_tokens.py,sha256=C9z35hyx-PvSAYdC1LNf3rg9_wsEIY56hkEczelbad0,6015
|
|
24
24
|
sqlsaber/config/providers.py,sha256=JFjeJv1K5Q93zWSlWq3hAvgch1TlgoF0qFa0KJROkKY,2957
|
|
25
25
|
sqlsaber/config/settings.py,sha256=vgb_RXaM-7DgbxYDmWNw1cSyMqwys4j3qNCvM4bljwI,5586
|
|
26
26
|
sqlsaber/database/__init__.py,sha256=a_gtKRJnZVO8-fEZI7g3Z8YnGa6Nio-5Y50PgVp07ss,176
|
|
27
|
-
sqlsaber/database/connection.py,sha256=
|
|
28
|
-
sqlsaber/database/resolver.py,sha256=
|
|
29
|
-
sqlsaber/database/schema.py,sha256=
|
|
27
|
+
sqlsaber/database/connection.py,sha256=J3U08Qu7NQrmem0jPM5XKIHPmPJE927IiLhN8zA6oLo,19392
|
|
28
|
+
sqlsaber/database/resolver.py,sha256=wSCcn__aCqwIfpt_LCjtW2Zgb8RpG5PlmwwZHli1q_U,3628
|
|
29
|
+
sqlsaber/database/schema.py,sha256=9HXTb5O_nlS2aNDeyv7EXhX7_kN2hs6rbPnJ8fnLyWk,41260
|
|
30
30
|
sqlsaber/mcp/__init__.py,sha256=COdWq7wauPBp5Ew8tfZItFzbcLDSEkHBJSMhxzy8C9c,112
|
|
31
31
|
sqlsaber/mcp/mcp.py,sha256=X12oCMZYAtgJ7MNuh5cqz8y3lALrOzkXWcfpuY0Ijxk,3950
|
|
32
32
|
sqlsaber/memory/__init__.py,sha256=GiWkU6f6YYVV0EvvXDmFWe_CxarmDCql05t70MkTEWs,63
|
|
@@ -39,9 +39,9 @@ sqlsaber/tools/base.py,sha256=mHhvAj27BHmckyvuDLCPlAQdzABJyYxd9SJnaYAwwuA,1777
|
|
|
39
39
|
sqlsaber/tools/enums.py,sha256=CH32mL-0k9ZA18911xLpNtsgpV6tB85TktMj6uqGz54,411
|
|
40
40
|
sqlsaber/tools/instructions.py,sha256=X-x8maVkkyi16b6Tl0hcAFgjiYceZaSwyWTfmrvx8U8,9024
|
|
41
41
|
sqlsaber/tools/registry.py,sha256=HWOQMsNIdL4XZS6TeNUyrL-5KoSDH6PHsWd3X66o-18,3211
|
|
42
|
-
sqlsaber/tools/sql_tools.py,sha256=
|
|
43
|
-
sqlsaber-0.
|
|
44
|
-
sqlsaber-0.
|
|
45
|
-
sqlsaber-0.
|
|
46
|
-
sqlsaber-0.
|
|
47
|
-
sqlsaber-0.
|
|
42
|
+
sqlsaber/tools/sql_tools.py,sha256=j4yRqfKokPFnZ_tEZPrWU5WStDc3Mexo1fWZ8KsmUjQ,9965
|
|
43
|
+
sqlsaber-0.25.0.dist-info/METADATA,sha256=9Q2AsBv4I78FLo8Uezmnv_fCch3jIKgv1gzBBm1cVB4,6243
|
|
44
|
+
sqlsaber-0.25.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
45
|
+
sqlsaber-0.25.0.dist-info/entry_points.txt,sha256=qEbOB7OffXPFgyJc7qEIJlMEX5RN9xdzLmWZa91zCQQ,162
|
|
46
|
+
sqlsaber-0.25.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
47
|
+
sqlsaber-0.25.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|