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.
Files changed (20) hide show
  1. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/.gitignore +3 -0
  2. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/PKG-INFO +3 -3
  3. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/config.py +7 -2
  4. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/connector.py +251 -44
  5. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/pyproject.toml +3 -3
  6. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/scripts/init_tpch_data.py +151 -15
  7. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/conftest.py +2 -1
  8. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/conftest.py +152 -15
  9. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/test_integration.py +9 -4
  10. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/test_tpch.py +7 -1
  11. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/unit/test_config.py +2 -1
  12. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/unit/test_connector_unit.py +131 -11
  13. datus_postgresql-0.1.5/tests/unit/test_migration_mixin.py +89 -0
  14. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/README.md +0 -0
  15. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/__init__.py +0 -0
  16. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/datus_postgresql/handlers.py +0 -0
  17. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/docker-compose.yml +0 -0
  18. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/__init__.py +0 -0
  19. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/integration/__init__.py +0 -0
  20. {datus_postgresql-0.1.2 → datus_postgresql-0.1.5}/tests/unit/__init__.py +0 -0
@@ -138,3 +138,6 @@ Thumbs.db
138
138
 
139
139
 
140
140
  .omc
141
+
142
+ uv.toml
143
+ */uv.toml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datus-postgresql
3
- Version: 0.1.2
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.0
18
- Requires-Dist: datus-sqlalchemy>=0.1.2
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(default="", description="PostgreSQL password", json_schema_extra={"input_type": "password"})
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", description="SSL mode (disable, allow, prefer, require, verify-ca, verify-full)"
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__(connection_string, dialect="postgresql", timeout_seconds=config.timeout_seconds)
80
- self.database_name = database
81
- self.schema_name = config.schema_name or "public"
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 {"pg_catalog", "information_schema", "pg_toast", "pg_temp_1", "pg_toast_temp_1"}
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
- @staticmethod
98
- def _quote_identifier(identifier: str) -> str:
99
- """Safely wrap identifiers with double quotes for PostgreSQL."""
100
- escaped = identifier.replace('"', '""')
101
- return f'"{escaped}"'
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
- # Materialized views use pg_matviews
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 = '{schema_name}'"
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 = '{schema_name}'"
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": self.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('{schema_name}.{table_name}'::regclass, true) as definition
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 = '{schema_name}' AND matviewname = '{table_name}'
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._quote_identifier(col['name'])} {col['type']}"
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._quote_identifier(c) for c in pk_cols)
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, catalog_name: str = "", database_name: str = "", schema_name: str = "", tables: Optional[List[str]] = None
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, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
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 = '{schema_name}'
361
- AND tc.table_name = '{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.table_schema = '{schema_name}'
368
- AND c.table_name = '{table_name}'
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
- sql = "SELECT schema_name FROM information_schema.schemata"
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 do_switch_context(self, catalog_name: str = "", database_name: str = "", schema_name: str = ""):
425
- """Switch schema context by updating self.schema_name.
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
- Note: All queries use explicit schema qualification via full_name(),
428
- so we only need to update self.schema_name here.
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
- self.schema_name = schema_name
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, "", "", schema_name)
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, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
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, catalog_name: str = "", database_name: str = "", schema_name: str = "", table_name: str = ""
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._quote_identifier(schema_name)}.{self._quote_identifier(table_name)}"
518
- return self._quote_identifier(table_name)
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, tables: Optional[List[str]] = None, catalog_name: str = "", database_name: str = "", schema_name: str = ""
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, "", "", schema_name)
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.2"
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.0",
22
- "datus-sqlalchemy>=0.1.2",
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
  ]