datus-postgresql 0.1.2__tar.gz → 0.1.5__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.
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/.gitignore +3 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/PKG-INFO +3 -3
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/config.py +7 -2
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/connector.py +251 -44
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/pyproject.toml +3 -3
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/scripts/init_tpch_data.py +151 -15
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/conftest.py +2 -1
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/conftest.py +152 -15
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/test_integration.py +9 -4
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/test_tpch.py +7 -1
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/unit/test_config.py +2 -1
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/unit/test_connector_unit.py +131 -11
- datus_postgresql-0.1.5/tests/unit/test_migration_mixin.py +89 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/README.md +0 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/__init__.py +0 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/handlers.py +0 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/docker-compose.yml +0 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/__init__.py +0 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/__init__.py +0 -0
- {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/unit/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datus-postgresql
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: PostgreSQL database adapter for Datus
|
|
5
5
|
Project-URL: Homepage, https://github.com/Datus-ai/datus-db-adapters
|
|
6
6
|
Project-URL: Repository, https://github.com/Datus-ai/datus-db-adapters
|
|
@@ -14,8 +14,8 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Requires-Python: >=3.12
|
|
17
|
-
Requires-Dist: datus-db-core>=0.1.
|
|
18
|
-
Requires-Dist: datus-sqlalchemy>=0.1.
|
|
17
|
+
Requires-Dist: datus-db-core>=0.1.3
|
|
18
|
+
Requires-Dist: datus-sqlalchemy>=0.1.6
|
|
19
19
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
20
20
|
Requires-Dist: pydantic>=2.0.0
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
@@ -15,10 +15,15 @@ class PostgreSQLConfig(BaseModel):
|
|
|
15
15
|
host: str = Field(default="127.0.0.1", description="PostgreSQL server host")
|
|
16
16
|
port: int = Field(default=5432, description="PostgreSQL server port")
|
|
17
17
|
username: str = Field(..., description="PostgreSQL username")
|
|
18
|
-
password: str = Field(
|
|
18
|
+
password: str = Field(
|
|
19
|
+
default="",
|
|
20
|
+
description="PostgreSQL password",
|
|
21
|
+
json_schema_extra={"input_type": "password"},
|
|
22
|
+
)
|
|
19
23
|
database: Optional[str] = Field(default=None, description="Default database name")
|
|
20
24
|
schema_name: Optional[str] = Field(default="public", alias="schema", description="Default schema name")
|
|
21
25
|
sslmode: str = Field(
|
|
22
|
-
default="prefer",
|
|
26
|
+
default="prefer",
|
|
27
|
+
description="SSL mode (disable, allow, prefer, require, verify-ca, verify-full)",
|
|
23
28
|
)
|
|
24
29
|
timeout_seconds: int = Field(default=30, description="Connection timeout in seconds")
|
|
@@ -2,12 +2,22 @@
|
|
|
2
2
|
# Licensed under the Apache License, Version 2.0.
|
|
3
3
|
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
4
|
|
|
5
|
+
from collections import OrderedDict
|
|
5
6
|
from typing import Any, Dict, List, Optional, Set, Union, override
|
|
6
7
|
from urllib.parse import quote_plus
|
|
7
8
|
|
|
8
|
-
from datus_db_core import TABLE_TYPE, DatusDbException, ErrorCode, get_logger, list_to_in_str
|
|
9
|
-
from datus_sqlalchemy import SQLAlchemyConnector
|
|
10
9
|
from pydantic import BaseModel, Field
|
|
10
|
+
from sqlalchemy import create_engine, text
|
|
11
|
+
|
|
12
|
+
from datus_db_core import (
|
|
13
|
+
TABLE_TYPE,
|
|
14
|
+
DatusDbException,
|
|
15
|
+
ErrorCode,
|
|
16
|
+
MigrationTargetMixin,
|
|
17
|
+
get_logger,
|
|
18
|
+
list_to_in_str,
|
|
19
|
+
)
|
|
20
|
+
from datus_sqlalchemy import SQLAlchemyConnector
|
|
11
21
|
|
|
12
22
|
from .config import PostgreSQLConfig
|
|
13
23
|
|
|
@@ -43,7 +53,7 @@ def _get_metadata_config(table_type: TABLE_TYPE) -> TableMetadataNames:
|
|
|
43
53
|
return METADATA_DICT[table_type]
|
|
44
54
|
|
|
45
55
|
|
|
46
|
-
class PostgreSQLConnector(SQLAlchemyConnector):
|
|
56
|
+
class PostgreSQLConnector(SQLAlchemyConnector, MigrationTargetMixin):
|
|
47
57
|
"""PostgreSQL database connector."""
|
|
48
58
|
|
|
49
59
|
def __init__(self, config: Union[PostgreSQLConfig, dict]):
|
|
@@ -59,7 +69,6 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
59
69
|
elif not isinstance(config, PostgreSQLConfig):
|
|
60
70
|
raise TypeError(f"config must be PostgreSQLConfig or dict, got {type(config)}")
|
|
61
71
|
|
|
62
|
-
self.config = config
|
|
63
72
|
self.host = config.host
|
|
64
73
|
self.port = config.port
|
|
65
74
|
self.username = config.username
|
|
@@ -76,9 +85,18 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
76
85
|
f"{database}?sslmode={config.sslmode}"
|
|
77
86
|
)
|
|
78
87
|
|
|
79
|
-
super().__init__(
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
super().__init__(
|
|
89
|
+
connection_string,
|
|
90
|
+
dialect="postgresql",
|
|
91
|
+
timeout_seconds=config.timeout_seconds,
|
|
92
|
+
)
|
|
93
|
+
# Set after super().__init__() so BaseSqlConnector doesn't overwrite
|
|
94
|
+
# with a plain ConnectionConfig (which lacks sslmode, etc.)
|
|
95
|
+
self.config = config
|
|
96
|
+
self._default_database = database
|
|
97
|
+
self._default_schema = config.schema_name or "public"
|
|
98
|
+
self._engines: OrderedDict = OrderedDict() # LRU cache: database_name -> engine
|
|
99
|
+
self._max_engines = 8
|
|
82
100
|
|
|
83
101
|
# ==================== System Resources ====================
|
|
84
102
|
|
|
@@ -90,15 +108,26 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
90
108
|
@override
|
|
91
109
|
def _sys_schemas(self) -> Set[str]:
|
|
92
110
|
"""System schemas to filter out."""
|
|
93
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
"pg_catalog",
|
|
113
|
+
"information_schema",
|
|
114
|
+
"pg_toast",
|
|
115
|
+
"pg_temp_1",
|
|
116
|
+
"pg_toast_temp_1",
|
|
117
|
+
}
|
|
94
118
|
|
|
95
119
|
# ==================== Utility Methods ====================
|
|
96
120
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
121
|
+
# quote_identifier: uses BaseSqlConnector default (ANSI double quotes)
|
|
122
|
+
|
|
123
|
+
def _build_connection_string(self, database_name: str) -> str:
|
|
124
|
+
"""Build a PostgreSQL connection string for a given database."""
|
|
125
|
+
encoded_username = quote_plus(self.username) if self.username else ""
|
|
126
|
+
encoded_password = quote_plus(self.password) if self.password else ""
|
|
127
|
+
return (
|
|
128
|
+
f"postgresql+psycopg2://{encoded_username}:{encoded_password}"
|
|
129
|
+
f"@{self.host}:{self.port}/{database_name}?sslmode={self.config.sslmode}"
|
|
130
|
+
)
|
|
102
131
|
|
|
103
132
|
# ==================== Metadata Retrieval ====================
|
|
104
133
|
|
|
@@ -122,15 +151,18 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
122
151
|
List of metadata dictionaries
|
|
123
152
|
"""
|
|
124
153
|
self.connect()
|
|
154
|
+
database_name = database_name or self.database_name
|
|
125
155
|
schema_name = schema_name or self.schema_name
|
|
126
156
|
|
|
127
157
|
# Get metadata configuration
|
|
128
158
|
metadata_config = _get_metadata_config(table_type)
|
|
129
159
|
|
|
130
160
|
if table_type == "mv":
|
|
131
|
-
#
|
|
161
|
+
# pg_matviews is scoped to the current database connection.
|
|
162
|
+
# Use a temporary connection if a different database is requested (thread-safe).
|
|
163
|
+
safe_schema = schema_name.replace("'", "''") if schema_name else ""
|
|
132
164
|
if schema_name:
|
|
133
|
-
where = f"schemaname = '{
|
|
165
|
+
where = f"schemaname = '{safe_schema}'"
|
|
134
166
|
else:
|
|
135
167
|
where = f"{list_to_in_str('schemaname not in', list(self._sys_schemas()))}"
|
|
136
168
|
|
|
@@ -139,10 +171,13 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
139
171
|
FROM pg_matviews
|
|
140
172
|
WHERE {where}
|
|
141
173
|
"""
|
|
174
|
+
query_result = self._execute_pandas(query, database_name=database_name)
|
|
142
175
|
else:
|
|
143
|
-
# Tables and views use information_schema
|
|
176
|
+
# Tables and views use information_schema (supports table_catalog filter)
|
|
177
|
+
safe_schema = schema_name.replace("'", "''") if schema_name else ""
|
|
178
|
+
safe_db = database_name.replace("'", "''") if database_name else ""
|
|
144
179
|
if schema_name:
|
|
145
|
-
where = f"table_schema = '{
|
|
180
|
+
where = f"table_schema = '{safe_schema}'"
|
|
146
181
|
else:
|
|
147
182
|
where = f"{list_to_in_str('table_schema not in', list(self._sys_schemas()))}"
|
|
148
183
|
|
|
@@ -154,10 +189,9 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
154
189
|
query = f"""
|
|
155
190
|
SELECT table_schema, table_name
|
|
156
191
|
FROM information_schema.{metadata_config.info_table}
|
|
157
|
-
WHERE {where} {type_filter}
|
|
192
|
+
WHERE table_catalog = '{safe_db}' AND {where} {type_filter}
|
|
158
193
|
"""
|
|
159
|
-
|
|
160
|
-
query_result = self._execute_pandas(query)
|
|
194
|
+
query_result = self._execute_pandas(query, database_name=database_name)
|
|
161
195
|
|
|
162
196
|
# Format results
|
|
163
197
|
result = []
|
|
@@ -168,7 +202,7 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
168
202
|
{
|
|
169
203
|
"identifier": self.identifier(schema_name=schema, table_name=tb_name),
|
|
170
204
|
"catalog_name": "",
|
|
171
|
-
"database_name":
|
|
205
|
+
"database_name": database_name,
|
|
172
206
|
"schema_name": schema,
|
|
173
207
|
"table_name": tb_name,
|
|
174
208
|
"table_type": table_type,
|
|
@@ -190,10 +224,13 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
190
224
|
"""
|
|
191
225
|
full_name = self.full_name(schema_name=schema_name, table_name=table_name)
|
|
192
226
|
|
|
227
|
+
safe_schema = schema_name.replace("'", "''") if schema_name else ""
|
|
228
|
+
safe_table = table_name.replace("'", "''") if table_name else ""
|
|
229
|
+
|
|
193
230
|
if object_type == "VIEW":
|
|
194
231
|
# Get view definition
|
|
195
232
|
sql = f"""
|
|
196
|
-
SELECT pg_get_viewdef('{
|
|
233
|
+
SELECT pg_get_viewdef('{safe_schema}.{safe_table}'::regclass, true) as definition
|
|
197
234
|
"""
|
|
198
235
|
result = self._execute_pandas(sql)
|
|
199
236
|
if not result.empty and result["definition"][0]:
|
|
@@ -205,7 +242,7 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
205
242
|
sql = f"""
|
|
206
243
|
SELECT definition
|
|
207
244
|
FROM pg_matviews
|
|
208
|
-
WHERE schemaname = '{
|
|
245
|
+
WHERE schemaname = '{safe_schema}' AND matviewname = '{safe_table}'
|
|
209
246
|
"""
|
|
210
247
|
result = self._execute_pandas(sql)
|
|
211
248
|
if not result.empty and result["definition"][0]:
|
|
@@ -221,7 +258,7 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
221
258
|
col_defs = []
|
|
222
259
|
pk_cols = []
|
|
223
260
|
for col in columns:
|
|
224
|
-
col_def = f" {self.
|
|
261
|
+
col_def = f" {self.quote_identifier(col['name'])} {col['type']}"
|
|
225
262
|
if not col.get("nullable", True):
|
|
226
263
|
col_def += " NOT NULL"
|
|
227
264
|
if col.get("default_value"):
|
|
@@ -233,7 +270,7 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
233
270
|
ddl = f"CREATE TABLE {full_name} (\n"
|
|
234
271
|
ddl += ",\n".join(col_defs)
|
|
235
272
|
if pk_cols:
|
|
236
|
-
pk_names = ", ".join(self.
|
|
273
|
+
pk_names = ", ".join(self.quote_identifier(c) for c in pk_cols)
|
|
237
274
|
ddl += f",\n PRIMARY KEY ({pk_names})"
|
|
238
275
|
ddl += "\n);"
|
|
239
276
|
return ddl
|
|
@@ -307,7 +344,11 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
307
344
|
|
|
308
345
|
@override
|
|
309
346
|
def get_tables_with_ddl(
|
|
310
|
-
self,
|
|
347
|
+
self,
|
|
348
|
+
catalog_name: str = "",
|
|
349
|
+
database_name: str = "",
|
|
350
|
+
schema_name: str = "",
|
|
351
|
+
tables: Optional[List[str]] = None,
|
|
311
352
|
) -> List[Dict[str, str]]:
|
|
312
353
|
"""Get tables with DDL statements."""
|
|
313
354
|
return self._get_objects_with_ddl("table", tables, catalog_name, database_name, schema_name)
|
|
@@ -321,7 +362,11 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
321
362
|
|
|
322
363
|
@override
|
|
323
364
|
def get_schema(
|
|
324
|
-
self,
|
|
365
|
+
self,
|
|
366
|
+
catalog_name: str = "",
|
|
367
|
+
database_name: str = "",
|
|
368
|
+
schema_name: str = "",
|
|
369
|
+
table_name: str = "",
|
|
325
370
|
) -> List[Dict[str, Any]]:
|
|
326
371
|
"""
|
|
327
372
|
Get table schema using INFORMATION_SCHEMA.
|
|
@@ -338,8 +383,13 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
338
383
|
if not table_name:
|
|
339
384
|
return []
|
|
340
385
|
|
|
386
|
+
database_name = database_name or self.database_name
|
|
341
387
|
schema_name = schema_name or self.schema_name
|
|
342
388
|
|
|
389
|
+
safe_db = database_name.replace("'", "''") if database_name else ""
|
|
390
|
+
safe_schema = schema_name.replace("'", "''") if schema_name else ""
|
|
391
|
+
safe_table = table_name.replace("'", "''") if table_name else ""
|
|
392
|
+
|
|
343
393
|
# Use INFORMATION_SCHEMA to get schema with comments
|
|
344
394
|
sql = f"""
|
|
345
395
|
SELECT
|
|
@@ -357,15 +407,16 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
357
407
|
ON tc.constraint_name = kcu.constraint_name
|
|
358
408
|
AND tc.table_schema = kcu.table_schema
|
|
359
409
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
360
|
-
AND tc.table_schema = '{
|
|
361
|
-
AND tc.table_name = '{
|
|
410
|
+
AND tc.table_schema = '{safe_schema}'
|
|
411
|
+
AND tc.table_name = '{safe_table}'
|
|
362
412
|
) pk ON c.column_name = pk.column_name
|
|
363
413
|
LEFT JOIN pg_catalog.pg_statio_all_tables st
|
|
364
414
|
ON st.schemaname = c.table_schema AND st.relname = c.table_name
|
|
365
415
|
LEFT JOIN pg_catalog.pg_description pgd
|
|
366
416
|
ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position
|
|
367
|
-
WHERE c.
|
|
368
|
-
AND c.
|
|
417
|
+
WHERE c.table_catalog = '{safe_db}'
|
|
418
|
+
AND c.table_schema = '{safe_schema}'
|
|
419
|
+
AND c.table_name = '{safe_table}'
|
|
369
420
|
ORDER BY c.ordinal_position
|
|
370
421
|
"""
|
|
371
422
|
query_result = self._execute_pandas(sql)
|
|
@@ -403,7 +454,9 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
403
454
|
@override
|
|
404
455
|
def get_schemas(self, catalog_name: str = "", database_name: str = "", include_sys: bool = False) -> List[str]:
|
|
405
456
|
"""Get list of schemas in the current database."""
|
|
406
|
-
|
|
457
|
+
database_name = database_name or self.database_name
|
|
458
|
+
safe_db = database_name.replace("'", "''") if database_name else ""
|
|
459
|
+
sql = f"SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '{safe_db}'"
|
|
407
460
|
result = self._execute_pandas(sql)
|
|
408
461
|
schemas = result["schema_name"].tolist()
|
|
409
462
|
|
|
@@ -420,15 +473,88 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
420
473
|
"""Get schema name for SQLAlchemy Inspector."""
|
|
421
474
|
return schema_name or self.schema_name
|
|
422
475
|
|
|
476
|
+
def _get_engine(self, database_name: str = ""):
|
|
477
|
+
"""Get or create engine for the given database. Thread-safe.
|
|
478
|
+
|
|
479
|
+
PostgreSQL requires different connection strings per database,
|
|
480
|
+
so each database gets its own engine with connection pool.
|
|
481
|
+
Uses LRU eviction (max 8 engines) to avoid holding too many connections.
|
|
482
|
+
"""
|
|
483
|
+
db = database_name or self.database_name
|
|
484
|
+
with self._engine_lock:
|
|
485
|
+
if db in self._engines:
|
|
486
|
+
self._engines.move_to_end(db)
|
|
487
|
+
return self._engines[db]
|
|
488
|
+
conn_str = self._build_connection_string(db)
|
|
489
|
+
engine = create_engine(
|
|
490
|
+
conn_str,
|
|
491
|
+
pool_size=5,
|
|
492
|
+
max_overflow=10,
|
|
493
|
+
pool_timeout=self.timeout_seconds,
|
|
494
|
+
pool_recycle=3600,
|
|
495
|
+
pool_pre_ping=True,
|
|
496
|
+
)
|
|
497
|
+
self._engines[db] = engine
|
|
498
|
+
while len(self._engines) > self._max_engines:
|
|
499
|
+
_, evicted = self._engines.popitem(last=False)
|
|
500
|
+
try:
|
|
501
|
+
evicted.dispose()
|
|
502
|
+
except Exception as e:
|
|
503
|
+
logger.warning(f"Error disposing evicted engine: {e}")
|
|
504
|
+
return engine
|
|
505
|
+
|
|
423
506
|
@override
|
|
424
|
-
def
|
|
425
|
-
"""
|
|
507
|
+
def _conn(self, catalog_name: str = "", database_name: str = "", schema_name: str = ""):
|
|
508
|
+
"""Checkout connection from the correct per-database engine. Thread-safe.
|
|
426
509
|
|
|
427
|
-
|
|
428
|
-
|
|
510
|
+
Overrides base _conn() to avoid writing to shared self.engine.
|
|
511
|
+
Each thread gets a connection from the engine matching its database_name.
|
|
512
|
+
"""
|
|
513
|
+
from contextlib import contextmanager
|
|
514
|
+
|
|
515
|
+
@contextmanager
|
|
516
|
+
def _pg_conn():
|
|
517
|
+
effective_database = database_name or self.database_name
|
|
518
|
+
effective_schema = schema_name or self.schema_name
|
|
519
|
+
effective_catalog = catalog_name or self.catalog_name
|
|
520
|
+
engine = self._get_engine(effective_database)
|
|
521
|
+
conn = engine.connect()
|
|
522
|
+
try:
|
|
523
|
+
self.do_switch_context(conn, effective_catalog, effective_database, effective_schema)
|
|
524
|
+
yield conn
|
|
525
|
+
except Exception:
|
|
526
|
+
try:
|
|
527
|
+
conn.rollback()
|
|
528
|
+
except Exception:
|
|
529
|
+
pass
|
|
530
|
+
raise
|
|
531
|
+
finally:
|
|
532
|
+
conn.close()
|
|
533
|
+
|
|
534
|
+
return _pg_conn()
|
|
535
|
+
|
|
536
|
+
@override
|
|
537
|
+
def close(self):
|
|
538
|
+
"""Dispose all engines (per-database pool + parent engine)."""
|
|
539
|
+
for engine in self._engines.values():
|
|
540
|
+
try:
|
|
541
|
+
engine.dispose()
|
|
542
|
+
except Exception as e:
|
|
543
|
+
logger.warning(f"Error disposing engine: {e}")
|
|
544
|
+
self._engines.clear()
|
|
545
|
+
# Dispose parent engine that may have been created via connect()/_ensure_engine()
|
|
546
|
+
super().close()
|
|
547
|
+
|
|
548
|
+
@override
|
|
549
|
+
def do_switch_context(self, conn, catalog_name: str = "", database_name: str = "", schema_name: str = ""):
|
|
550
|
+
"""Apply schema context to a connection.
|
|
551
|
+
|
|
552
|
+
Database switching is handled by _conn() which picks the right engine
|
|
553
|
+
based on the effective database_name.
|
|
429
554
|
"""
|
|
430
555
|
if schema_name:
|
|
431
|
-
|
|
556
|
+
conn.execute(text(f"SET search_path TO {self.quote_identifier(schema_name)}"))
|
|
557
|
+
conn.commit()
|
|
432
558
|
|
|
433
559
|
# ==================== Sample Data ====================
|
|
434
560
|
|
|
@@ -477,7 +603,7 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
477
603
|
return result
|
|
478
604
|
|
|
479
605
|
# Otherwise get metadata and query all tables
|
|
480
|
-
metadata = self._get_metadata(table_type, "",
|
|
606
|
+
metadata = self._get_metadata(table_type, "", database_name, schema_name)
|
|
481
607
|
for meta in metadata:
|
|
482
608
|
full_name = self.full_name(schema_name=meta["schema_name"], table_name=meta["table_name"])
|
|
483
609
|
sql = f"SELECT * FROM {full_name} LIMIT {top_n}"
|
|
@@ -499,28 +625,109 @@ class PostgreSQLConnector(SQLAlchemyConnector):
|
|
|
499
625
|
|
|
500
626
|
@override
|
|
501
627
|
def identifier(
|
|
502
|
-
self,
|
|
628
|
+
self,
|
|
629
|
+
catalog_name: str = "",
|
|
630
|
+
database_name: str = "",
|
|
631
|
+
schema_name: str = "",
|
|
632
|
+
table_name: str = "",
|
|
503
633
|
) -> str:
|
|
504
634
|
"""Generate a unique identifier for a table."""
|
|
635
|
+
database_name = database_name or self.database_name
|
|
505
636
|
schema_name = schema_name or self.schema_name
|
|
637
|
+
if database_name and schema_name:
|
|
638
|
+
return f"{database_name}.{schema_name}.{table_name}"
|
|
506
639
|
if schema_name:
|
|
507
640
|
return f"{schema_name}.{table_name}"
|
|
508
641
|
return table_name
|
|
509
642
|
|
|
510
643
|
@override
|
|
511
644
|
def full_name(
|
|
512
|
-
self,
|
|
645
|
+
self,
|
|
646
|
+
catalog_name: str = "",
|
|
647
|
+
database_name: str = "",
|
|
648
|
+
schema_name: str = "",
|
|
649
|
+
table_name: str = "",
|
|
513
650
|
) -> str:
|
|
514
651
|
"""Build fully-qualified table name."""
|
|
652
|
+
database_name = database_name or self.database_name
|
|
515
653
|
schema_name = schema_name or self.schema_name
|
|
654
|
+
if database_name and schema_name:
|
|
655
|
+
return f"{self.quote_identifier(database_name)}.{self.quote_identifier(schema_name)}.{self.quote_identifier(table_name)}"
|
|
516
656
|
if schema_name:
|
|
517
|
-
return f"{self.
|
|
518
|
-
return self.
|
|
657
|
+
return f"{self.quote_identifier(schema_name)}.{self.quote_identifier(table_name)}"
|
|
658
|
+
return self.quote_identifier(table_name)
|
|
519
659
|
|
|
520
660
|
@override
|
|
521
661
|
def _reset_filter_tables(
|
|
522
|
-
self,
|
|
662
|
+
self,
|
|
663
|
+
tables: Optional[List[str]] = None,
|
|
664
|
+
catalog_name: str = "",
|
|
665
|
+
database_name: str = "",
|
|
666
|
+
schema_name: str = "",
|
|
523
667
|
) -> List[str]:
|
|
524
668
|
"""Reset filter tables with full names."""
|
|
525
669
|
schema_name = schema_name or self.schema_name
|
|
526
|
-
return super()._reset_filter_tables(tables, "",
|
|
670
|
+
return super()._reset_filter_tables(tables, "", database_name, schema_name)
|
|
671
|
+
|
|
672
|
+
# ==================== MigrationTargetMixin ====================
|
|
673
|
+
|
|
674
|
+
def describe_migration_capabilities(self) -> Dict[str, Any]:
|
|
675
|
+
return {
|
|
676
|
+
"supported": True,
|
|
677
|
+
"dialect_family": "postgres-like",
|
|
678
|
+
"requires": [], # OLTP — no distribution/partition required
|
|
679
|
+
"forbids": [
|
|
680
|
+
"DUPLICATE KEY (StarRocks-only)",
|
|
681
|
+
"DISTRIBUTED BY HASH ... BUCKETS (StarRocks-only)",
|
|
682
|
+
"ENGINE = (MySQL/ClickHouse syntax)",
|
|
683
|
+
],
|
|
684
|
+
"type_hints": {
|
|
685
|
+
"HUGEINT": "NUMERIC(38,0) (Postgres has no HUGEINT/LARGEINT)",
|
|
686
|
+
"LARGEINT": "NUMERIC(38,0)",
|
|
687
|
+
"unbounded VARCHAR": "TEXT (prefer TEXT over unbounded VARCHAR)",
|
|
688
|
+
"TIMESTAMP WITH TIME ZONE": "TIMESTAMPTZ",
|
|
689
|
+
"JSON": "JSONB (prefer for indexing)",
|
|
690
|
+
"BOOLEAN": "BOOLEAN (no TINYINT cast needed)",
|
|
691
|
+
},
|
|
692
|
+
"example_ddl": (
|
|
693
|
+
"CREATE TABLE public.t (\n"
|
|
694
|
+
" id BIGSERIAL PRIMARY KEY,\n"
|
|
695
|
+
" name VARCHAR(255),\n"
|
|
696
|
+
" created_at TIMESTAMPTZ DEFAULT now()\n"
|
|
697
|
+
")"
|
|
698
|
+
),
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
def suggest_table_layout(self, columns: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
702
|
+
# Postgres is OLTP — no distribution keys or bucketing required
|
|
703
|
+
return {}
|
|
704
|
+
|
|
705
|
+
def validate_ddl(self, ddl: str) -> List[str]:
|
|
706
|
+
errors: List[str] = []
|
|
707
|
+
upper = ddl.upper()
|
|
708
|
+
|
|
709
|
+
if "DUPLICATE KEY" in upper:
|
|
710
|
+
errors.append("DUPLICATE KEY is StarRocks-only syntax; Postgres does not support it")
|
|
711
|
+
if "BUCKETS" in upper and "DISTRIBUTED BY" in upper:
|
|
712
|
+
errors.append("DISTRIBUTED BY ... BUCKETS is StarRocks syntax; Postgres does not support it")
|
|
713
|
+
if "ENGINE =" in upper or "ENGINE=" in upper:
|
|
714
|
+
errors.append("ENGINE clause is MySQL/ClickHouse syntax; not supported in Postgres")
|
|
715
|
+
if "ORDER BY" in upper and "CREATE TABLE" in upper:
|
|
716
|
+
# Rough heuristic: top-level ORDER BY inside CREATE TABLE is ClickHouse's
|
|
717
|
+
# MergeTree syntax. Postgres allows ORDER BY inside CTAS SELECT, so this
|
|
718
|
+
# check is intentionally loose (only flags when accompanied by ENGINE).
|
|
719
|
+
if "ENGINE" in upper:
|
|
720
|
+
errors.append("ORDER BY inside CREATE TABLE is ClickHouse syntax; use CREATE INDEX in Postgres")
|
|
721
|
+
|
|
722
|
+
return errors
|
|
723
|
+
|
|
724
|
+
def map_source_type(self, source_dialect: str, source_type: str) -> Optional[str]:
|
|
725
|
+
import re as _re
|
|
726
|
+
|
|
727
|
+
base = _re.sub(r"\(.*\)", "", source_type.strip().upper()).strip()
|
|
728
|
+
overrides = {
|
|
729
|
+
"HUGEINT": "NUMERIC(38,0)",
|
|
730
|
+
"LARGEINT": "NUMERIC(38,0)",
|
|
731
|
+
"DATETIME": "TIMESTAMP",
|
|
732
|
+
}
|
|
733
|
+
return overrides.get(base)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "datus-postgresql"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5"
|
|
4
4
|
description = "PostgreSQL database adapter for Datus"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -18,8 +18,8 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
dependencies = [
|
|
21
|
-
"datus-db-core>=0.1.
|
|
22
|
-
"datus-sqlalchemy>=0.1.
|
|
21
|
+
"datus-db-core>=0.1.3",
|
|
22
|
+
"datus-sqlalchemy>=0.1.6",
|
|
23
23
|
"psycopg2-binary>=2.9.11",
|
|
24
24
|
"pydantic>=2.0.0",
|
|
25
25
|
]
|