datus-postgresql 0.1.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.
- datus_postgresql/__init__.py +16 -0
- datus_postgresql/config.py +24 -0
- datus_postgresql/connector.py +530 -0
- datus_postgresql-0.1.0.dist-info/METADATA +81 -0
- datus_postgresql-0.1.0.dist-info/RECORD +7 -0
- datus_postgresql-0.1.0.dist-info/WHEEL +4 -0
- datus_postgresql-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
from .config import PostgreSQLConfig
|
|
6
|
+
from .connector import PostgreSQLConnector
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
__all__ = ["PostgreSQLConnector", "PostgreSQLConfig", "register"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register():
|
|
13
|
+
"""Register PostgreSQL connector with Datus registry."""
|
|
14
|
+
from datus.tools.db_tools import connector_registry
|
|
15
|
+
|
|
16
|
+
connector_registry.register("postgresql", PostgreSQLConnector, config_class=PostgreSQLConfig)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostgreSQLConfig(BaseModel):
|
|
11
|
+
"""PostgreSQL-specific configuration."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
14
|
+
|
|
15
|
+
host: str = Field(default="127.0.0.1", description="PostgreSQL server host")
|
|
16
|
+
port: int = Field(default=5432, description="PostgreSQL server port")
|
|
17
|
+
username: str = Field(..., description="PostgreSQL username")
|
|
18
|
+
password: str = Field(default="", description="PostgreSQL password", json_schema_extra={"input_type": "password"})
|
|
19
|
+
database: Optional[str] = Field(default=None, description="Default database name")
|
|
20
|
+
schema_name: Optional[str] = Field(default="public", alias="schema", description="Default schema name")
|
|
21
|
+
sslmode: str = Field(
|
|
22
|
+
default="prefer", description="SSL mode (disable, allow, prefer, require, verify-ca, verify-full)"
|
|
23
|
+
)
|
|
24
|
+
timeout_seconds: int = Field(default=30, description="Connection timeout in seconds")
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Set, Union, override
|
|
6
|
+
from urllib.parse import quote_plus
|
|
7
|
+
|
|
8
|
+
from datus.schemas.base import TABLE_TYPE
|
|
9
|
+
from datus.tools.db_tools.base import list_to_in_str
|
|
10
|
+
from datus.utils.constants import DBType
|
|
11
|
+
from datus.utils.exceptions import DatusException, ErrorCode
|
|
12
|
+
from datus.utils.loggings import get_logger
|
|
13
|
+
from datus_sqlalchemy import SQLAlchemyConnector
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from .config import PostgreSQLConfig
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TableMetadataNames(BaseModel):
|
|
22
|
+
"""Metadata configuration for different PostgreSQL object types."""
|
|
23
|
+
|
|
24
|
+
info_table: str = Field(..., description="INFORMATION_SCHEMA table name or pg_catalog view")
|
|
25
|
+
table_types: Optional[List[str]] = Field(default=None, description="TABLE_TYPE values in INFORMATION_SCHEMA")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Metadata configuration for PostgreSQL objects
|
|
29
|
+
METADATA_DICT: Dict[TABLE_TYPE, TableMetadataNames] = {
|
|
30
|
+
"table": TableMetadataNames(
|
|
31
|
+
info_table="tables",
|
|
32
|
+
table_types=["BASE TABLE"],
|
|
33
|
+
),
|
|
34
|
+
"view": TableMetadataNames(
|
|
35
|
+
info_table="views",
|
|
36
|
+
),
|
|
37
|
+
"mv": TableMetadataNames(
|
|
38
|
+
info_table="pg_matviews",
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_metadata_config(table_type: TABLE_TYPE) -> TableMetadataNames:
|
|
44
|
+
"""Get metadata configuration for given table type."""
|
|
45
|
+
if table_type not in METADATA_DICT:
|
|
46
|
+
raise DatusException(ErrorCode.COMMON_FIELD_INVALID, f"Invalid table type '{table_type}'")
|
|
47
|
+
return METADATA_DICT[table_type]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PostgreSQLConnector(SQLAlchemyConnector):
|
|
51
|
+
"""PostgreSQL database connector."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, config: Union[PostgreSQLConfig, dict]):
|
|
54
|
+
"""
|
|
55
|
+
Initialize PostgreSQL connector.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
config: PostgreSQLConfig object or dict with configuration
|
|
59
|
+
"""
|
|
60
|
+
# Handle config object or dict
|
|
61
|
+
if isinstance(config, dict):
|
|
62
|
+
config = PostgreSQLConfig(**config)
|
|
63
|
+
elif not isinstance(config, PostgreSQLConfig):
|
|
64
|
+
raise TypeError(f"config must be PostgreSQLConfig or dict, got {type(config)}")
|
|
65
|
+
|
|
66
|
+
self.config = config
|
|
67
|
+
self.host = config.host
|
|
68
|
+
self.port = config.port
|
|
69
|
+
self.username = config.username
|
|
70
|
+
self.password = config.password
|
|
71
|
+
database = config.database or "postgres"
|
|
72
|
+
|
|
73
|
+
# URL encode username and password to handle special characters
|
|
74
|
+
encoded_username = quote_plus(self.username) if self.username else ""
|
|
75
|
+
encoded_password = quote_plus(self.password) if self.password else ""
|
|
76
|
+
|
|
77
|
+
# Build connection string
|
|
78
|
+
connection_string = (
|
|
79
|
+
f"postgresql+psycopg2://{encoded_username}:{encoded_password}@{self.host}:{self.port}/"
|
|
80
|
+
f"{database}?sslmode={config.sslmode}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
super().__init__(connection_string, dialect=DBType.POSTGRESQL, timeout_seconds=config.timeout_seconds)
|
|
84
|
+
self.database_name = database
|
|
85
|
+
self.schema_name = config.schema_name or "public"
|
|
86
|
+
|
|
87
|
+
# ==================== System Resources ====================
|
|
88
|
+
|
|
89
|
+
@override
|
|
90
|
+
def _sys_databases(self) -> Set[str]:
|
|
91
|
+
"""System databases to filter out."""
|
|
92
|
+
return {"template0", "template1"}
|
|
93
|
+
|
|
94
|
+
@override
|
|
95
|
+
def _sys_schemas(self) -> Set[str]:
|
|
96
|
+
"""System schemas to filter out."""
|
|
97
|
+
return {"pg_catalog", "information_schema", "pg_toast", "pg_temp_1", "pg_toast_temp_1"}
|
|
98
|
+
|
|
99
|
+
# ==================== Utility Methods ====================
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _quote_identifier(identifier: str) -> str:
|
|
103
|
+
"""Safely wrap identifiers with double quotes for PostgreSQL."""
|
|
104
|
+
escaped = identifier.replace('"', '""')
|
|
105
|
+
return f'"{escaped}"'
|
|
106
|
+
|
|
107
|
+
# ==================== Metadata Retrieval ====================
|
|
108
|
+
|
|
109
|
+
def _get_metadata(
|
|
110
|
+
self,
|
|
111
|
+
table_type: TABLE_TYPE = "table",
|
|
112
|
+
catalog_name: str = "",
|
|
113
|
+
database_name: str = "",
|
|
114
|
+
schema_name: str = "",
|
|
115
|
+
) -> List[Dict[str, str]]:
|
|
116
|
+
"""
|
|
117
|
+
Get metadata for tables/views from INFORMATION_SCHEMA or pg_catalog.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
table_type: Type of object (table, view, mv)
|
|
121
|
+
catalog_name: Catalog name (unused in PostgreSQL)
|
|
122
|
+
database_name: Database name (unused, uses current database)
|
|
123
|
+
schema_name: Schema name to query
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of metadata dictionaries
|
|
127
|
+
"""
|
|
128
|
+
self.connect()
|
|
129
|
+
schema_name = schema_name or self.schema_name
|
|
130
|
+
|
|
131
|
+
# Get metadata configuration
|
|
132
|
+
metadata_config = _get_metadata_config(table_type)
|
|
133
|
+
|
|
134
|
+
if table_type == "mv":
|
|
135
|
+
# Materialized views use pg_matviews
|
|
136
|
+
if schema_name:
|
|
137
|
+
where = f"schemaname = '{schema_name}'"
|
|
138
|
+
else:
|
|
139
|
+
where = f"{list_to_in_str('schemaname not in', list(self._sys_schemas()))}"
|
|
140
|
+
|
|
141
|
+
query = f"""
|
|
142
|
+
SELECT schemaname as table_schema, matviewname as table_name
|
|
143
|
+
FROM pg_matviews
|
|
144
|
+
WHERE {where}
|
|
145
|
+
"""
|
|
146
|
+
else:
|
|
147
|
+
# Tables and views use information_schema
|
|
148
|
+
if schema_name:
|
|
149
|
+
where = f"table_schema = '{schema_name}'"
|
|
150
|
+
else:
|
|
151
|
+
where = f"{list_to_in_str('table_schema not in', list(self._sys_schemas()))}"
|
|
152
|
+
|
|
153
|
+
if table_type == "table":
|
|
154
|
+
type_filter = list_to_in_str("and table_type in", metadata_config.table_types)
|
|
155
|
+
else:
|
|
156
|
+
type_filter = ""
|
|
157
|
+
|
|
158
|
+
query = f"""
|
|
159
|
+
SELECT table_schema, table_name
|
|
160
|
+
FROM information_schema.{metadata_config.info_table}
|
|
161
|
+
WHERE {where} {type_filter}
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
query_result = self._execute_pandas(query)
|
|
165
|
+
|
|
166
|
+
# Format results
|
|
167
|
+
result = []
|
|
168
|
+
for i in range(len(query_result)):
|
|
169
|
+
schema = query_result["table_schema"][i]
|
|
170
|
+
tb_name = query_result["table_name"][i]
|
|
171
|
+
result.append(
|
|
172
|
+
{
|
|
173
|
+
"identifier": self.identifier(schema_name=schema, table_name=tb_name),
|
|
174
|
+
"catalog_name": "",
|
|
175
|
+
"database_name": self.database_name,
|
|
176
|
+
"schema_name": schema,
|
|
177
|
+
"table_name": tb_name,
|
|
178
|
+
"table_type": table_type,
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
def _get_ddl(self, schema_name: str, table_name: str, object_type: str = "TABLE") -> str:
|
|
184
|
+
"""
|
|
185
|
+
Get DDL for a table/view using pg_get_tabledef or reconstructing from metadata.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
schema_name: Schema name
|
|
189
|
+
table_name: Table name
|
|
190
|
+
object_type: Object type (TABLE, VIEW, MATERIALIZED VIEW)
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
DDL statement as string
|
|
194
|
+
"""
|
|
195
|
+
full_name = self.full_name(schema_name=schema_name, table_name=table_name)
|
|
196
|
+
|
|
197
|
+
if object_type == "VIEW":
|
|
198
|
+
# Get view definition
|
|
199
|
+
sql = f"""
|
|
200
|
+
SELECT pg_get_viewdef('{schema_name}.{table_name}'::regclass, true) as definition
|
|
201
|
+
"""
|
|
202
|
+
result = self._execute_pandas(sql)
|
|
203
|
+
if not result.empty and result["definition"][0]:
|
|
204
|
+
return f"CREATE VIEW {full_name} AS\n{result['definition'][0]}"
|
|
205
|
+
return f"-- DDL not available for {full_name}"
|
|
206
|
+
|
|
207
|
+
elif object_type == "MATERIALIZED VIEW":
|
|
208
|
+
# Get materialized view definition
|
|
209
|
+
sql = f"""
|
|
210
|
+
SELECT definition
|
|
211
|
+
FROM pg_matviews
|
|
212
|
+
WHERE schemaname = '{schema_name}' AND matviewname = '{table_name}'
|
|
213
|
+
"""
|
|
214
|
+
result = self._execute_pandas(sql)
|
|
215
|
+
if not result.empty and result["definition"][0]:
|
|
216
|
+
return f"CREATE MATERIALIZED VIEW {full_name} AS\n{result['definition'][0]}"
|
|
217
|
+
return f"-- DDL not available for {full_name}"
|
|
218
|
+
|
|
219
|
+
else:
|
|
220
|
+
# For tables, reconstruct DDL from column info
|
|
221
|
+
columns = self.get_schema(schema_name=schema_name, table_name=table_name)
|
|
222
|
+
if not columns:
|
|
223
|
+
return f"-- DDL not available for {full_name}"
|
|
224
|
+
|
|
225
|
+
col_defs = []
|
|
226
|
+
pk_cols = []
|
|
227
|
+
for col in columns:
|
|
228
|
+
col_def = f" {self._quote_identifier(col['name'])} {col['type']}"
|
|
229
|
+
if not col.get("nullable", True):
|
|
230
|
+
col_def += " NOT NULL"
|
|
231
|
+
if col.get("default_value"):
|
|
232
|
+
col_def += f" DEFAULT {col['default_value']}"
|
|
233
|
+
col_defs.append(col_def)
|
|
234
|
+
if col.get("pk"):
|
|
235
|
+
pk_cols.append(col["name"])
|
|
236
|
+
|
|
237
|
+
ddl = f"CREATE TABLE {full_name} (\n"
|
|
238
|
+
ddl += ",\n".join(col_defs)
|
|
239
|
+
if pk_cols:
|
|
240
|
+
pk_names = ", ".join(self._quote_identifier(c) for c in pk_cols)
|
|
241
|
+
ddl += f",\n PRIMARY KEY ({pk_names})"
|
|
242
|
+
ddl += "\n);"
|
|
243
|
+
return ddl
|
|
244
|
+
|
|
245
|
+
def _get_objects_with_ddl(
|
|
246
|
+
self,
|
|
247
|
+
table_type: TABLE_TYPE = "table",
|
|
248
|
+
tables: Optional[List[str]] = None,
|
|
249
|
+
catalog_name: str = "",
|
|
250
|
+
database_name: str = "",
|
|
251
|
+
schema_name: str = "",
|
|
252
|
+
) -> List[Dict[str, str]]:
|
|
253
|
+
"""
|
|
254
|
+
Get metadata with DDL statements.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
table_type: Type of object
|
|
258
|
+
tables: Optional list of specific tables to retrieve
|
|
259
|
+
catalog_name: Catalog name (unused)
|
|
260
|
+
database_name: Database name (unused)
|
|
261
|
+
schema_name: Schema name
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
List of metadata dictionaries with DDL
|
|
265
|
+
"""
|
|
266
|
+
result = []
|
|
267
|
+
filter_tables = self._reset_filter_tables(tables, catalog_name, database_name, schema_name)
|
|
268
|
+
|
|
269
|
+
object_type_map = {
|
|
270
|
+
"table": "TABLE",
|
|
271
|
+
"view": "VIEW",
|
|
272
|
+
"mv": "MATERIALIZED VIEW",
|
|
273
|
+
}
|
|
274
|
+
object_type = object_type_map.get(table_type, "TABLE")
|
|
275
|
+
|
|
276
|
+
for meta in self._get_metadata(table_type, catalog_name, database_name, schema_name):
|
|
277
|
+
full_name = self.full_name(schema_name=meta["schema_name"], table_name=meta["table_name"])
|
|
278
|
+
|
|
279
|
+
# Skip if not in filter list
|
|
280
|
+
if filter_tables and full_name not in filter_tables:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Get DDL
|
|
284
|
+
try:
|
|
285
|
+
ddl = self._get_ddl(meta["schema_name"], meta["table_name"], object_type)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.warning(f"Could not get DDL for {full_name}: {e}")
|
|
288
|
+
ddl = f"-- DDL not available for {meta['table_name']}"
|
|
289
|
+
|
|
290
|
+
meta["definition"] = ddl
|
|
291
|
+
result.append(meta)
|
|
292
|
+
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
@override
|
|
296
|
+
def get_tables(self, catalog_name: str = "", database_name: str = "", schema_name: str = "") -> List[str]:
|
|
297
|
+
"""Get list of table names."""
|
|
298
|
+
return [meta["table_name"] for meta in self._get_metadata("table", catalog_name, database_name, schema_name)]
|
|
299
|
+
|
|
300
|
+
@override
|
|
301
|
+
def get_views(self, catalog_name: str = "", database_name: str = "", schema_name: str = "") -> List[str]:
|
|
302
|
+
"""Get list of view names."""
|
|
303
|
+
return [meta["table_name"] for meta in self._get_metadata("view", catalog_name, database_name, schema_name)]
|
|
304
|
+
|
|
305
|
+
@override
|
|
306
|
+
def get_materialized_views(
|
|
307
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
|
|
308
|
+
) -> List[str]:
|
|
309
|
+
"""Get list of materialized view names."""
|
|
310
|
+
return [meta["table_name"] for meta in self._get_metadata("mv", catalog_name, database_name, schema_name)]
|
|
311
|
+
|
|
312
|
+
@override
|
|
313
|
+
def get_tables_with_ddl(
|
|
314
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = "", tables: Optional[List[str]] = None
|
|
315
|
+
) -> List[Dict[str, str]]:
|
|
316
|
+
"""Get tables with DDL statements."""
|
|
317
|
+
return self._get_objects_with_ddl("table", tables, catalog_name, database_name, schema_name)
|
|
318
|
+
|
|
319
|
+
@override
|
|
320
|
+
def get_views_with_ddl(
|
|
321
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
|
|
322
|
+
) -> List[Dict[str, str]]:
|
|
323
|
+
"""Get views with DDL statements."""
|
|
324
|
+
return self._get_objects_with_ddl("view", None, catalog_name, database_name, schema_name)
|
|
325
|
+
|
|
326
|
+
@override
|
|
327
|
+
def get_schema(
|
|
328
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
|
|
329
|
+
) -> List[Dict[str, Any]]:
|
|
330
|
+
"""
|
|
331
|
+
Get table schema using INFORMATION_SCHEMA.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
catalog_name: Catalog name (unused)
|
|
335
|
+
database_name: Database name (unused)
|
|
336
|
+
schema_name: Schema name
|
|
337
|
+
table_name: Table name
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
List of column information dictionaries
|
|
341
|
+
"""
|
|
342
|
+
if not table_name:
|
|
343
|
+
return []
|
|
344
|
+
|
|
345
|
+
schema_name = schema_name or self.schema_name
|
|
346
|
+
|
|
347
|
+
# Use INFORMATION_SCHEMA to get schema with comments
|
|
348
|
+
sql = f"""
|
|
349
|
+
SELECT
|
|
350
|
+
c.column_name as field,
|
|
351
|
+
c.data_type as type,
|
|
352
|
+
c.is_nullable as nullable,
|
|
353
|
+
c.column_default as default_value,
|
|
354
|
+
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_pk,
|
|
355
|
+
pgd.description as comment
|
|
356
|
+
FROM information_schema.columns c
|
|
357
|
+
LEFT JOIN (
|
|
358
|
+
SELECT kcu.column_name
|
|
359
|
+
FROM information_schema.table_constraints tc
|
|
360
|
+
JOIN information_schema.key_column_usage kcu
|
|
361
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
362
|
+
AND tc.table_schema = kcu.table_schema
|
|
363
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
364
|
+
AND tc.table_schema = '{schema_name}'
|
|
365
|
+
AND tc.table_name = '{table_name}'
|
|
366
|
+
) pk ON c.column_name = pk.column_name
|
|
367
|
+
LEFT JOIN pg_catalog.pg_statio_all_tables st
|
|
368
|
+
ON st.schemaname = c.table_schema AND st.relname = c.table_name
|
|
369
|
+
LEFT JOIN pg_catalog.pg_description pgd
|
|
370
|
+
ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position
|
|
371
|
+
WHERE c.table_schema = '{schema_name}'
|
|
372
|
+
AND c.table_name = '{table_name}'
|
|
373
|
+
ORDER BY c.ordinal_position
|
|
374
|
+
"""
|
|
375
|
+
query_result = self._execute_pandas(sql)
|
|
376
|
+
|
|
377
|
+
result = []
|
|
378
|
+
for i in range(len(query_result)):
|
|
379
|
+
result.append(
|
|
380
|
+
{
|
|
381
|
+
"cid": i,
|
|
382
|
+
"name": query_result["field"][i],
|
|
383
|
+
"type": query_result["type"][i],
|
|
384
|
+
"nullable": query_result["nullable"][i] == "YES",
|
|
385
|
+
"default_value": query_result["default_value"][i],
|
|
386
|
+
"pk": bool(query_result["is_pk"][i]),
|
|
387
|
+
"comment": query_result["comment"][i] if query_result["comment"][i] else None,
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
# ==================== Database/Schema Management ====================
|
|
393
|
+
|
|
394
|
+
@override
|
|
395
|
+
def get_databases(self, catalog_name: str = "", include_sys: bool = False) -> List[str]:
|
|
396
|
+
"""Get list of databases."""
|
|
397
|
+
sql = "SELECT datname FROM pg_database WHERE datistemplate = false"
|
|
398
|
+
result = self._execute_pandas(sql)
|
|
399
|
+
databases = result["datname"].tolist()
|
|
400
|
+
|
|
401
|
+
if not include_sys:
|
|
402
|
+
sys_dbs = self._sys_databases()
|
|
403
|
+
databases = [db for db in databases if db not in sys_dbs]
|
|
404
|
+
|
|
405
|
+
return databases
|
|
406
|
+
|
|
407
|
+
@override
|
|
408
|
+
def get_schemas(self, catalog_name: str = "", database_name: str = "", include_sys: bool = False) -> List[str]:
|
|
409
|
+
"""Get list of schemas in the current database."""
|
|
410
|
+
sql = "SELECT schema_name FROM information_schema.schemata"
|
|
411
|
+
result = self._execute_pandas(sql)
|
|
412
|
+
schemas = result["schema_name"].tolist()
|
|
413
|
+
|
|
414
|
+
if not include_sys:
|
|
415
|
+
sys_schemas = self._sys_schemas()
|
|
416
|
+
schemas = [s for s in schemas if s not in sys_schemas]
|
|
417
|
+
|
|
418
|
+
return schemas
|
|
419
|
+
|
|
420
|
+
@override
|
|
421
|
+
def _sqlalchemy_schema(
|
|
422
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
|
|
423
|
+
) -> Optional[str]:
|
|
424
|
+
"""Get schema name for SQLAlchemy Inspector."""
|
|
425
|
+
return schema_name or self.schema_name
|
|
426
|
+
|
|
427
|
+
@override
|
|
428
|
+
def do_switch_context(self, catalog_name: str = "", database_name: str = "", schema_name: str = ""):
|
|
429
|
+
"""Switch schema context by updating self.schema_name.
|
|
430
|
+
|
|
431
|
+
Note: All queries use explicit schema qualification via full_name(),
|
|
432
|
+
so we only need to update self.schema_name here.
|
|
433
|
+
"""
|
|
434
|
+
if schema_name:
|
|
435
|
+
self.schema_name = schema_name
|
|
436
|
+
|
|
437
|
+
# ==================== Sample Data ====================
|
|
438
|
+
|
|
439
|
+
def get_sample_rows(
|
|
440
|
+
self,
|
|
441
|
+
tables: Optional[List[str]] = None,
|
|
442
|
+
top_n: int = 5,
|
|
443
|
+
catalog_name: str = "",
|
|
444
|
+
database_name: str = "",
|
|
445
|
+
schema_name: str = "",
|
|
446
|
+
table_type: TABLE_TYPE = "table",
|
|
447
|
+
) -> List[Dict[str, str]]:
|
|
448
|
+
"""Get sample rows from tables."""
|
|
449
|
+
# Delegate to base class for unsupported table types (e.g., "full")
|
|
450
|
+
if table_type == "full" or table_type not in METADATA_DICT:
|
|
451
|
+
return super().get_sample_rows(
|
|
452
|
+
tables=tables,
|
|
453
|
+
top_n=top_n,
|
|
454
|
+
catalog_name=catalog_name,
|
|
455
|
+
database_name=database_name,
|
|
456
|
+
schema_name=schema_name,
|
|
457
|
+
table_type=table_type,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
self.connect()
|
|
461
|
+
schema_name = schema_name or self.schema_name
|
|
462
|
+
result = []
|
|
463
|
+
|
|
464
|
+
# If specific tables provided, query those
|
|
465
|
+
if tables:
|
|
466
|
+
for table_name in tables:
|
|
467
|
+
full_name = self.full_name(schema_name=schema_name, table_name=table_name)
|
|
468
|
+
sql = f"SELECT * FROM {full_name} LIMIT {top_n}"
|
|
469
|
+
df = self._execute_pandas(sql)
|
|
470
|
+
if not df.empty:
|
|
471
|
+
result.append(
|
|
472
|
+
{
|
|
473
|
+
"identifier": self.identifier(schema_name=schema_name, table_name=table_name),
|
|
474
|
+
"catalog_name": "",
|
|
475
|
+
"database_name": self.database_name,
|
|
476
|
+
"schema_name": schema_name,
|
|
477
|
+
"table_name": table_name,
|
|
478
|
+
"sample_rows": df.to_csv(index=False),
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
# Otherwise get metadata and query all tables
|
|
484
|
+
metadata = self._get_metadata(table_type, "", "", schema_name)
|
|
485
|
+
for meta in metadata:
|
|
486
|
+
full_name = self.full_name(schema_name=meta["schema_name"], table_name=meta["table_name"])
|
|
487
|
+
sql = f"SELECT * FROM {full_name} LIMIT {top_n}"
|
|
488
|
+
df = self._execute_pandas(sql)
|
|
489
|
+
if not df.empty:
|
|
490
|
+
result.append(
|
|
491
|
+
{
|
|
492
|
+
"identifier": meta["identifier"],
|
|
493
|
+
"catalog_name": "",
|
|
494
|
+
"database_name": self.database_name,
|
|
495
|
+
"schema_name": meta["schema_name"],
|
|
496
|
+
"table_name": meta["table_name"],
|
|
497
|
+
"sample_rows": df.to_csv(index=False),
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
# ==================== Utility Methods ====================
|
|
503
|
+
|
|
504
|
+
@override
|
|
505
|
+
def identifier(
|
|
506
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
|
|
507
|
+
) -> str:
|
|
508
|
+
"""Generate a unique identifier for a table."""
|
|
509
|
+
schema_name = schema_name or self.schema_name
|
|
510
|
+
if schema_name:
|
|
511
|
+
return f"{schema_name}.{table_name}"
|
|
512
|
+
return table_name
|
|
513
|
+
|
|
514
|
+
@override
|
|
515
|
+
def full_name(
|
|
516
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
|
|
517
|
+
) -> str:
|
|
518
|
+
"""Build fully-qualified table name."""
|
|
519
|
+
schema_name = schema_name or self.schema_name
|
|
520
|
+
if schema_name:
|
|
521
|
+
return f"{self._quote_identifier(schema_name)}.{self._quote_identifier(table_name)}"
|
|
522
|
+
return self._quote_identifier(table_name)
|
|
523
|
+
|
|
524
|
+
@override
|
|
525
|
+
def _reset_filter_tables(
|
|
526
|
+
self, tables: Optional[List[str]] = None, catalog_name: str = "", database_name: str = "", schema_name: str = ""
|
|
527
|
+
) -> List[str]:
|
|
528
|
+
"""Reset filter tables with full names."""
|
|
529
|
+
schema_name = schema_name or self.schema_name
|
|
530
|
+
return super()._reset_filter_tables(tables, "", "", schema_name)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datus-postgresql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PostgreSQL database adapter for Datus
|
|
5
|
+
Project-URL: Homepage, https://github.com/Datus-ai/datus-db-adapters
|
|
6
|
+
Project-URL: Repository, https://github.com/Datus-ai/datus-db-adapters
|
|
7
|
+
Project-URL: Issues, https://github.com/Datus-ai/datus-db-adapters/issues
|
|
8
|
+
Author-email: DatusAI <support@datus.ai>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: adapter,database,datus,postgresql
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: datus-agent>0.2.1
|
|
18
|
+
Requires-Dist: datus-sqlalchemy>=0.1.2
|
|
19
|
+
Requires-Dist: psycopg2-binary>=2.9.11
|
|
20
|
+
Requires-Dist: pydantic>=2.0.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# datus-postgresql
|
|
24
|
+
|
|
25
|
+
PostgreSQL database adapter for Datus.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install datus-postgresql
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from datus_postgresql import PostgreSQLConnector, PostgreSQLConfig
|
|
37
|
+
|
|
38
|
+
# Using config object
|
|
39
|
+
config = PostgreSQLConfig(
|
|
40
|
+
host="localhost",
|
|
41
|
+
port=5432,
|
|
42
|
+
username="postgres",
|
|
43
|
+
password="password",
|
|
44
|
+
database="mydb",
|
|
45
|
+
schema_name="public",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
connector = PostgreSQLConnector(config)
|
|
49
|
+
|
|
50
|
+
# Or using dict
|
|
51
|
+
connector = PostgreSQLConnector({
|
|
52
|
+
"host": "localhost",
|
|
53
|
+
"port": 5432,
|
|
54
|
+
"username": "postgres",
|
|
55
|
+
"password": "password",
|
|
56
|
+
"database": "mydb",
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
# Test connection
|
|
60
|
+
connector.test_connection()
|
|
61
|
+
|
|
62
|
+
# Execute queries
|
|
63
|
+
result = connector.execute({"sql_query": "SELECT * FROM users"})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configuration Options
|
|
67
|
+
|
|
68
|
+
| Option | Type | Default | Description |
|
|
69
|
+
|--------|------|---------|-------------|
|
|
70
|
+
| host | str | "127.0.0.1" | PostgreSQL server host |
|
|
71
|
+
| port | int | 5432 | PostgreSQL server port |
|
|
72
|
+
| username | str | required | PostgreSQL username |
|
|
73
|
+
| password | str | "" | PostgreSQL password |
|
|
74
|
+
| database | str | None | Default database name |
|
|
75
|
+
| schema | str | "public" | Default schema name |
|
|
76
|
+
| sslmode | str | "prefer" | SSL mode |
|
|
77
|
+
| timeout_seconds | int | 30 | Connection timeout |
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
datus_postgresql/__init__.py,sha256=BnQyHwfxdDimY9U_MEItMDKGqqd0oVjXx-naFKKQaqE,555
|
|
2
|
+
datus_postgresql/config.py,sha256=jvl2XC_A-B1Bb_fFEUi5QxsOBqWgLC-stYLxGAQOmPU,1148
|
|
3
|
+
datus_postgresql/connector.py,sha256=gB5dEeozeGXEc5PVAmRFQWT5I-rbCt6FtxiTVN-btrs,20440
|
|
4
|
+
datus_postgresql-0.1.0.dist-info/METADATA,sha256=rHXdndcV6dBpiBdl0CxJFVmyB3EQdsiNj4w2rTVsnAM,2153
|
|
5
|
+
datus_postgresql-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
datus_postgresql-0.1.0.dist-info/entry_points.txt,sha256=GHceNS-xqgYKFLZEm8Jhw7iznrZ86Ty-SHadUzT7Fao,56
|
|
7
|
+
datus_postgresql-0.1.0.dist-info/RECORD,,
|