continuo-postgres-adapter 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/.gitignore +1 -0
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/PKG-INFO +2 -2
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/continuo_postgres_adapter/adapter.py +180 -40
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/pyproject.toml +2 -2
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/tests/conftest.py +12 -2
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/tests/test_adapter_runtime_postgres.py +86 -0
- continuo_postgres_adapter-0.3.0/tests/test_integration_postgres_conformance.py +103 -0
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/tests/test_integration_runtime_postgres.py +109 -1
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/README.md +0 -0
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/continuo_postgres_adapter/__init__.py +0 -0
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/tests/__init__.py +0 -0
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/tests/test_adapter_postgres_validation.py +0 -0
- {continuo_postgres_adapter-0.2.1 → continuo_postgres_adapter-0.3.0}/tests/test_integration_postgres_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: continuo-postgres-adapter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Postgres warehouse adapter for Continuo: validation and python-node runtime.
|
|
5
5
|
Author: Simone Carolini
|
|
6
6
|
Maintainer: Simone Carolini
|
|
@@ -8,7 +8,7 @@ Classifier: Development Status :: 4 - Beta
|
|
|
8
8
|
Classifier: Intended Audience :: Developers
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.14
|
|
10
10
|
Requires-Python: >=3.14
|
|
11
|
-
Requires-Dist: continuo-engine-contract==0.
|
|
11
|
+
Requires-Dist: continuo-engine-contract==0.8.0
|
|
12
12
|
Requires-Dist: psycopg2-binary==2.9.12
|
|
13
13
|
Requires-Dist: pyarrow==25.0.1
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
@@ -8,8 +8,10 @@ One class covers both roles Continuo asks of an engine:
|
|
|
8
8
|
without scanning data. Schema creation is serialized on a session advisory
|
|
9
9
|
lock because parallel root validation nodes race on ``CREATE SCHEMA``.
|
|
10
10
|
* **Python-node data-plane I/O** — ``fetch`` executes one declared read and
|
|
11
|
-
returns an Arrow table; ``ensure_table
|
|
12
|
-
|
|
11
|
+
returns an Arrow table; ``ensure_table`` builds the table (an upsert key as
|
|
12
|
+
its ``PRIMARY KEY``); ``load`` atomically replaces its contents, ``append``
|
|
13
|
+
adds rows and ``_upsert`` inserts or overwrites by key, all through one
|
|
14
|
+
insert helper; ``table_columns`` reads its shape.
|
|
13
15
|
|
|
14
16
|
The connection runs with ``autocommit = True``: every method owns its
|
|
15
17
|
transaction explicitly rather than relying on psycopg2's implicit
|
|
@@ -17,22 +19,29 @@ per-connection transaction. A single-statement method (``drop_schema``,
|
|
|
17
19
|
``build_empty_from_sql``, ``clone_empty_from_prod``) needs nothing further —
|
|
18
20
|
under autocommit each statement commits as it runs. A method that must apply
|
|
19
21
|
several statements atomically (``build_empty_from_columns``, ``check_binds``,
|
|
20
|
-
``ensure_table``, ``load``) wraps them in its own explicit
|
|
22
|
+
``ensure_table``, ``load``, ``append``, ``_upsert``) wraps them in its own explicit
|
|
21
23
|
``BEGIN``/``COMMIT``, with a ``ROLLBACK`` on the error path so a mid-sequence
|
|
22
24
|
failure leaves no partial change and no aborted transaction behind for the
|
|
23
25
|
next call on the same connection.
|
|
24
26
|
"""
|
|
27
|
+
import contextlib
|
|
25
28
|
import hashlib
|
|
26
29
|
import logging
|
|
27
30
|
import os
|
|
28
31
|
|
|
32
|
+
from collections.abc import Iterator, Sequence
|
|
29
33
|
from typing import Any
|
|
30
34
|
|
|
31
35
|
import psycopg2 # type: ignore[import-untyped]
|
|
32
36
|
import pyarrow as pa # type: ignore[import-untyped]
|
|
33
37
|
|
|
34
38
|
from continuo_engine_contract.config import ensure_known_keys # type: ignore[import-untyped]
|
|
35
|
-
from continuo_engine_contract.port import
|
|
39
|
+
from continuo_engine_contract.port import ( # type: ignore[import-untyped]
|
|
40
|
+
AppendCapable,
|
|
41
|
+
LiveColumn,
|
|
42
|
+
UpsertCapable,
|
|
43
|
+
WarehouseAdapter,
|
|
44
|
+
)
|
|
36
45
|
from continuo_engine_contract.sql import ensure_single_read # type: ignore[import-untyped]
|
|
37
46
|
from continuo_engine_contract.types import validate_column_type # type: ignore[import-untyped]
|
|
38
47
|
from psycopg2 import errors as pg_errors # type: ignore[import-untyped]
|
|
@@ -305,6 +314,65 @@ def _column_ddl(columns: list[dict[str, Any]]) -> "pg_sql.Composed":
|
|
|
305
314
|
return pg_sql.SQL(", ").join(col_defs)
|
|
306
315
|
|
|
307
316
|
|
|
317
|
+
def _primary_key_ddl(primary_key: Sequence[str]) -> "pg_sql.Composed":
|
|
318
|
+
"""``PRIMARY KEY (...)`` for an upsert node's table: enforced and indexed by postgres."""
|
|
319
|
+
return pg_sql.SQL("PRIMARY KEY ({})").format(
|
|
320
|
+
pg_sql.SQL(", ").join(pg_sql.Identifier(name) for name in primary_key)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# Rows per INSERT statement on every write path (execute_values' page_size).
|
|
325
|
+
_INSERT_PAGE_SIZE = 1000
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _insert_rows(
|
|
329
|
+
cur: Any,
|
|
330
|
+
schema: str,
|
|
331
|
+
table: str,
|
|
332
|
+
data: "pa.Table",
|
|
333
|
+
suffix: "pg_sql.Composable | None" = None,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Insert every row of *data* into ``schema.table`` inside the caller's transaction.
|
|
336
|
+
|
|
337
|
+
Batched INSERTs (``execute_values``, ``_INSERT_PAGE_SIZE`` rows per
|
|
338
|
+
statement) that name *data*'s columns, so the table's own column order
|
|
339
|
+
does not matter. *suffix* (an ``ON CONFLICT`` clause) follows the VALUES
|
|
340
|
+
list. ``execute_values`` reparses every percent token after the composable
|
|
341
|
+
identifiers have rendered, so literal percents in identifiers are escaped
|
|
342
|
+
and only its one values-list token is left unescaped. The single insert
|
|
343
|
+
path behind ``load``, ``append`` and ``_upsert``.
|
|
344
|
+
"""
|
|
345
|
+
columns = data.schema.names
|
|
346
|
+
values = [tuple(row[c] for c in columns) for row in data.to_pylist()]
|
|
347
|
+
prefix = pg_sql.SQL("INSERT INTO {}.{} ({})").format(
|
|
348
|
+
pg_sql.Identifier(schema),
|
|
349
|
+
pg_sql.Identifier(table),
|
|
350
|
+
pg_sql.SQL(", ").join(pg_sql.Identifier(c) for c in columns),
|
|
351
|
+
)
|
|
352
|
+
statement = prefix.as_string(cur).replace("%", "%%") + " VALUES %s"
|
|
353
|
+
if suffix is not None:
|
|
354
|
+
statement += " " + suffix.as_string(cur).replace("%", "%%")
|
|
355
|
+
execute_values(cur, statement, values, page_size=_INSERT_PAGE_SIZE)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _on_conflict(columns: Sequence[str], primary_key: Sequence[str]) -> "pg_sql.Composed":
|
|
359
|
+
"""The upsert's ``ON CONFLICT`` clause.
|
|
360
|
+
|
|
361
|
+
Overwrites every non-key column from ``EXCLUDED``; with every column in
|
|
362
|
+
the key there is nothing to overwrite, so ``DO NOTHING``.
|
|
363
|
+
"""
|
|
364
|
+
key = set(primary_key)
|
|
365
|
+
target = pg_sql.SQL(", ").join(pg_sql.Identifier(name) for name in primary_key)
|
|
366
|
+
updates = [name for name in columns if name not in key]
|
|
367
|
+
if not updates:
|
|
368
|
+
return pg_sql.SQL("ON CONFLICT ({}) DO NOTHING").format(target)
|
|
369
|
+
assignments = pg_sql.SQL(", ").join(
|
|
370
|
+
pg_sql.SQL("{} = EXCLUDED.{}").format(pg_sql.Identifier(name), pg_sql.Identifier(name))
|
|
371
|
+
for name in updates
|
|
372
|
+
)
|
|
373
|
+
return pg_sql.SQL("ON CONFLICT ({}) DO UPDATE SET {}").format(target, assignments)
|
|
374
|
+
|
|
375
|
+
|
|
308
376
|
def _arrow_table_from_rows(colnames: list[str], rows: list[tuple[Any, ...]]) -> "pa.Table":
|
|
309
377
|
"""Build a column-wise Arrow table from cursor description names and fetched rows.
|
|
310
378
|
|
|
@@ -337,7 +405,7 @@ def _arrow_table_from_rows(colnames: list[str], rows: list[tuple[Any, ...]]) ->
|
|
|
337
405
|
return pa.table({name: pa.array(values) for name, values in zip(colnames, by_column)})
|
|
338
406
|
|
|
339
407
|
|
|
340
|
-
class PostgresAdapter(WarehouseAdapter):
|
|
408
|
+
class PostgresAdapter(WarehouseAdapter, AppendCapable, UpsertCapable):
|
|
341
409
|
"""WarehouseAdapter speaking postgres over a psycopg2 connection."""
|
|
342
410
|
|
|
343
411
|
def __init__(self, conn: "psycopg2.extensions.connection") -> None:
|
|
@@ -571,6 +639,26 @@ class PostgresAdapter(WarehouseAdapter):
|
|
|
571
639
|
raise
|
|
572
640
|
return table
|
|
573
641
|
|
|
642
|
+
def table_columns(self, schema: str, table: str) -> list[LiveColumn]:
|
|
643
|
+
"""Names and nullability of ``schema.table`` from ``information_schema.columns``.
|
|
644
|
+
|
|
645
|
+
Names are matched exactly: this adapter creates every schema, table and
|
|
646
|
+
column with quoted identifiers, so the catalog holds them as written.
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
LookupError: If the table does not exist (or this role cannot see it).
|
|
650
|
+
"""
|
|
651
|
+
with self._conn.cursor() as cur:
|
|
652
|
+
cur.execute(
|
|
653
|
+
"SELECT column_name, is_nullable FROM information_schema.columns "
|
|
654
|
+
"WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position",
|
|
655
|
+
(schema, table),
|
|
656
|
+
)
|
|
657
|
+
rows = cur.fetchall()
|
|
658
|
+
if not rows:
|
|
659
|
+
raise LookupError(f"table {schema}.{table} does not exist")
|
|
660
|
+
return [LiveColumn(name=name, nullable=is_nullable == "YES") for name, is_nullable in rows]
|
|
661
|
+
|
|
574
662
|
@classmethod
|
|
575
663
|
def validate_config(
|
|
576
664
|
cls, config: dict[str, Any] | None, column_names: list[str]
|
|
@@ -609,6 +697,7 @@ class PostgresAdapter(WarehouseAdapter):
|
|
|
609
697
|
columns: list[dict[str, Any]],
|
|
610
698
|
*,
|
|
611
699
|
config: dict[str, Any],
|
|
700
|
+
primary_key: Sequence[str] = (),
|
|
612
701
|
) -> None:
|
|
613
702
|
"""CREATE TABLE IF NOT EXISTS with typed DDL compiled from *columns*.
|
|
614
703
|
|
|
@@ -620,17 +709,33 @@ class PostgresAdapter(WarehouseAdapter):
|
|
|
620
709
|
keyword-only, matching the contract 0.6.0 port signature. The create
|
|
621
710
|
and every index either all land or none do, in the same explicit
|
|
622
711
|
BEGIN/COMMIT/ROLLBACK shape build_empty_from_columns uses.
|
|
712
|
+
|
|
713
|
+
*primary_key* (an upsert node's key, in the declared column spelling)
|
|
714
|
+
becomes the created table's ``PRIMARY KEY``, which ``_upsert``'s
|
|
715
|
+
``ON CONFLICT`` needs. Like the indexes it applies only when this call
|
|
716
|
+
creates the table: an existing table without the constraint makes the
|
|
717
|
+
upsert fail at ``ON CONFLICT`` rather than collect duplicate keys.
|
|
623
718
|
"""
|
|
624
|
-
|
|
719
|
+
declared = [col["name"] for col in columns]
|
|
720
|
+
indexes = _validated_indexes(config, table, declared)
|
|
625
721
|
for col in columns:
|
|
626
722
|
validate_column_type(col["type"])
|
|
723
|
+
undeclared = [name for name in primary_key if name not in declared]
|
|
724
|
+
if undeclared:
|
|
725
|
+
raise ValueError(
|
|
726
|
+
f"primary key on undeclared column(s) {undeclared!r}; "
|
|
727
|
+
f"declared columns: {declared!r}"
|
|
728
|
+
)
|
|
627
729
|
|
|
628
730
|
self.ensure_schema(schema)
|
|
629
731
|
|
|
732
|
+
elements = _column_ddl(columns)
|
|
733
|
+
if primary_key:
|
|
734
|
+
elements = pg_sql.SQL(", ").join([elements, _primary_key_ddl(primary_key)])
|
|
630
735
|
stmt = pg_sql.SQL("CREATE TABLE IF NOT EXISTS {}.{} ({})").format(
|
|
631
736
|
pg_sql.Identifier(schema),
|
|
632
737
|
pg_sql.Identifier(table),
|
|
633
|
-
|
|
738
|
+
elements,
|
|
634
739
|
)
|
|
635
740
|
with self._conn.cursor() as cur:
|
|
636
741
|
cur.execute("BEGIN")
|
|
@@ -650,51 +755,86 @@ class PostgresAdapter(WarehouseAdapter):
|
|
|
650
755
|
logger.error("ensure_table rollback failed: %s", rollback_exc)
|
|
651
756
|
raise
|
|
652
757
|
|
|
653
|
-
|
|
654
|
-
|
|
758
|
+
@contextlib.contextmanager
|
|
759
|
+
def _transaction(self, operation: str) -> Iterator[Any]:
|
|
760
|
+
"""Run the body on one cursor inside an explicit ``BEGIN``/``COMMIT``.
|
|
655
761
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
0-row *data* is just a TRUNCATE. The transaction is opened explicitly
|
|
660
|
-
(``BEGIN``) rather than relying on an implicit one, matching every
|
|
661
|
-
other multi-statement method on this class under ``autocommit = True``.
|
|
762
|
+
Any error rolls back and re-raises. A rollback that fails as well is
|
|
763
|
+
only logged, so the caller sees the original error and the session is
|
|
764
|
+
never left in an aborted transaction.
|
|
662
765
|
"""
|
|
663
|
-
columns = data.schema.names
|
|
664
766
|
with self._conn.cursor() as cur:
|
|
665
767
|
cur.execute("BEGIN")
|
|
666
768
|
try:
|
|
667
|
-
cur
|
|
668
|
-
pg_sql.SQL("TRUNCATE {}.{}").format(
|
|
669
|
-
pg_sql.Identifier(schema), pg_sql.Identifier(table)
|
|
670
|
-
)
|
|
671
|
-
)
|
|
672
|
-
if data.num_rows:
|
|
673
|
-
rows = data.to_pylist()
|
|
674
|
-
values = [tuple(row[c] for c in columns) for row in rows]
|
|
675
|
-
insert_prefix = pg_sql.SQL("INSERT INTO {}.{} ({})").format(
|
|
676
|
-
pg_sql.Identifier(schema),
|
|
677
|
-
pg_sql.Identifier(table),
|
|
678
|
-
pg_sql.SQL(", ").join(pg_sql.Identifier(c) for c in columns),
|
|
679
|
-
)
|
|
680
|
-
# execute_values reparses every percent token after composable
|
|
681
|
-
# identifiers have rendered. Escape literal identifier percents,
|
|
682
|
-
# then append its one values-list expansion token unescaped.
|
|
683
|
-
insert_stmt = (
|
|
684
|
-
insert_prefix.as_string(cur).replace("%", "%%") + " VALUES %s"
|
|
685
|
-
)
|
|
686
|
-
logger.info(
|
|
687
|
-
"loading %d row(s) into %s.%s", data.num_rows, schema, table
|
|
688
|
-
)
|
|
689
|
-
execute_values(cur, insert_stmt, values, page_size=1000)
|
|
769
|
+
yield cur
|
|
690
770
|
cur.execute("COMMIT")
|
|
691
771
|
except Exception:
|
|
692
772
|
try:
|
|
693
773
|
cur.execute("ROLLBACK")
|
|
694
774
|
except psycopg2.Error as rollback_exc:
|
|
695
|
-
logger.error("
|
|
775
|
+
logger.error("%s rollback failed: %s", operation, rollback_exc)
|
|
696
776
|
raise
|
|
697
777
|
|
|
778
|
+
def load(self, schema: str, table: str, data: "pa.Table") -> None:
|
|
779
|
+
"""Atomically replace ``schema.table``'s contents with *data*.
|
|
780
|
+
|
|
781
|
+
One explicit transaction: TRUNCATE then batched inserts
|
|
782
|
+
(``_insert_rows``, page size 1000) that name the Arrow table's columns;
|
|
783
|
+
commits on success, rolls back and re-raises on any error. A 0-row
|
|
784
|
+
*data* is just a TRUNCATE. The transaction is opened explicitly
|
|
785
|
+
(``BEGIN``) rather than relying on an implicit one, matching every
|
|
786
|
+
other multi-statement method on this class under ``autocommit = True``.
|
|
787
|
+
"""
|
|
788
|
+
with self._transaction("load") as cur:
|
|
789
|
+
cur.execute(
|
|
790
|
+
pg_sql.SQL("TRUNCATE {}.{}").format(
|
|
791
|
+
pg_sql.Identifier(schema), pg_sql.Identifier(table)
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
if data.num_rows:
|
|
795
|
+
logger.info("loading %d row(s) into %s.%s", data.num_rows, schema, table)
|
|
796
|
+
_insert_rows(cur, schema, table, data)
|
|
797
|
+
|
|
798
|
+
def append(self, schema: str, table: str, data: "pa.Table") -> None:
|
|
799
|
+
"""Atomically insert every row of *data* into ``schema.table``; earlier rows stay.
|
|
800
|
+
|
|
801
|
+
One explicit transaction of batched inserts that name *data*'s columns
|
|
802
|
+
(the same insert path as ``load``, without the TRUNCATE). A 0-row
|
|
803
|
+
*data* opens no transaction at all.
|
|
804
|
+
"""
|
|
805
|
+
if not data.num_rows:
|
|
806
|
+
logger.info("append of 0 rows into %s.%s: nothing to write", schema, table)
|
|
807
|
+
return
|
|
808
|
+
with self._transaction("append") as cur:
|
|
809
|
+
logger.info("appending %d row(s) into %s.%s", data.num_rows, schema, table)
|
|
810
|
+
_insert_rows(cur, schema, table, data)
|
|
811
|
+
|
|
812
|
+
def _upsert(
|
|
813
|
+
self, schema: str, table: str, data: "pa.Table", primary_key: Sequence[str]
|
|
814
|
+
) -> None:
|
|
815
|
+
"""Insert new keys and overwrite existing ones in one transaction.
|
|
816
|
+
|
|
817
|
+
Batched ``INSERT ... ON CONFLICT (key) DO UPDATE`` (``DO NOTHING`` when
|
|
818
|
+
every column is in the key) through the shared insert path. Needs the
|
|
819
|
+
table's ``PRIMARY KEY`` (see ``ensure_table``): postgres rejects
|
|
820
|
+
``ON CONFLICT`` on columns without a matching constraint, so a table
|
|
821
|
+
created without it fails here instead of collecting duplicates. Keys
|
|
822
|
+
arrive unique (``UpsertCapable.upsert``), which also keeps one
|
|
823
|
+
statement from touching a row twice. A 0-row *data* opens no
|
|
824
|
+
transaction.
|
|
825
|
+
"""
|
|
826
|
+
if not data.num_rows:
|
|
827
|
+
logger.info("upsert of 0 rows into %s.%s: nothing to write", schema, table)
|
|
828
|
+
return
|
|
829
|
+
with self._transaction("upsert") as cur:
|
|
830
|
+
logger.info(
|
|
831
|
+
"upserting %d row(s) into %s.%s on (%s)",
|
|
832
|
+
data.num_rows, schema, table, ", ".join(primary_key),
|
|
833
|
+
)
|
|
834
|
+
_insert_rows(
|
|
835
|
+
cur, schema, table, data, suffix=_on_conflict(data.schema.names, primary_key)
|
|
836
|
+
)
|
|
837
|
+
|
|
698
838
|
def close(self) -> None:
|
|
699
839
|
"""Release the underlying connection."""
|
|
700
840
|
self._conn.close()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[project]
|
|
2
|
-
version = "0.
|
|
2
|
+
version = "0.3.0"
|
|
3
3
|
name = "continuo-postgres-adapter"
|
|
4
4
|
description = "Postgres warehouse adapter for Continuo: validation and python-node runtime."
|
|
5
5
|
authors = [{ name = "Simone Carolini" }]
|
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
# of this adapter would otherwise accept whatever contract release is
|
|
13
13
|
# newest. This adapter subclasses the contract's WarehouseAdapter port
|
|
14
14
|
# directly, so the pair must stay the tested one.
|
|
15
|
-
"continuo-engine-contract==0.
|
|
15
|
+
"continuo-engine-contract==0.8.0",
|
|
16
16
|
"psycopg2-binary==2.9.12",
|
|
17
17
|
"pyarrow==25.0.1",
|
|
18
18
|
]
|
|
@@ -43,8 +43,18 @@ def minio_container():
|
|
|
43
43
|
"-p", "0:9000",
|
|
44
44
|
"-e", "MINIO_ROOT_USER=minioadmin",
|
|
45
45
|
"-e", "MINIO_ROOT_PASSWORD=minioadmin",
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
# ghcr mirror of Bitnami's minio image (Docker Hub's minio/minio
|
|
47
|
+
# is no longer anonymously pullable). Its ENTRYPOINT is
|
|
48
|
+
# Bitnami's own setup script, not the `minio` binary, so it
|
|
49
|
+
# must be overridden for these args to reach `minio` directly;
|
|
50
|
+
# its data dir also has to be Bitnami's own
|
|
51
|
+
# /bitnami/minio/data (world-root-group-writable for the
|
|
52
|
+
# image's non-root user) since plain /data doesn't exist in
|
|
53
|
+
# this image and the binary can't create it at the
|
|
54
|
+
# filesystem root.
|
|
55
|
+
"--entrypoint", "minio",
|
|
56
|
+
"ghcr.io/carolsimone/continuo-minio:2025.7.23",
|
|
57
|
+
"server", "/bitnami/minio/data", "--address", ":9000",
|
|
48
58
|
],
|
|
49
59
|
check=True, capture_output=True, text=True, timeout=60,
|
|
50
60
|
)
|
|
@@ -549,3 +549,89 @@ def test_config_validation_survives_a_second_recognized_top_level_key(monkeypatc
|
|
|
549
549
|
conn = _FakeConnection()
|
|
550
550
|
PostgresAdapter(conn).ensure_table("s", "t", _ONE_COL, config={"fillfactor": 90})
|
|
551
551
|
assert conn.committed # DDL ran; no KeyError on the way in
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def test_load_of_zero_rows_truncates_in_one_explicit_transaction():
|
|
555
|
+
"""load's transaction shape, pinned across the insert-path refactor."""
|
|
556
|
+
conn = _FakeConnection()
|
|
557
|
+
PostgresAdapter(conn).load("s", "t", pa.table({"id": pa.array([], pa.int64())}))
|
|
558
|
+
(cur,) = conn.cursors
|
|
559
|
+
assert cur.statements == [
|
|
560
|
+
"BEGIN",
|
|
561
|
+
pg_sql.SQL("TRUNCATE {}.{}").format(pg_sql.Identifier("s"), pg_sql.Identifier("t")),
|
|
562
|
+
"COMMIT",
|
|
563
|
+
]
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def test_append_of_zero_rows_opens_no_transaction():
|
|
567
|
+
conn = _FakeConnection()
|
|
568
|
+
PostgresAdapter(conn).append("s", "t", pa.table({"id": pa.array([], pa.int64())}))
|
|
569
|
+
assert conn.cursors == []
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
_KEYED = [
|
|
573
|
+
{"name": "rate_date", "type": "DATE", "nullable": False},
|
|
574
|
+
{"name": "base", "type": "VARCHAR(3)", "nullable": False},
|
|
575
|
+
{"name": "rate", "type": "DECIMAL(12,6)", "nullable": True},
|
|
576
|
+
]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_ensure_table_with_a_primary_key_declares_it_in_the_create_table():
|
|
580
|
+
conn = _FakeConnection()
|
|
581
|
+
PostgresAdapter(conn).ensure_table(
|
|
582
|
+
"s", "t", _KEYED, config={}, primary_key=("rate_date", "base")
|
|
583
|
+
)
|
|
584
|
+
expected = pg_sql.SQL("CREATE TABLE IF NOT EXISTS {}.{} ({})").format(
|
|
585
|
+
pg_sql.Identifier("s"),
|
|
586
|
+
pg_sql.Identifier("t"),
|
|
587
|
+
pg_sql.SQL(", ").join([
|
|
588
|
+
adapter_module._column_ddl(_KEYED),
|
|
589
|
+
pg_sql.SQL("PRIMARY KEY ({})").format(
|
|
590
|
+
pg_sql.SQL(", ").join([pg_sql.Identifier("rate_date"), pg_sql.Identifier("base")])
|
|
591
|
+
),
|
|
592
|
+
]),
|
|
593
|
+
)
|
|
594
|
+
assert conn.cursors[-1].statements[1] == expected
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_ensure_table_without_a_primary_key_emits_todays_create_table():
|
|
598
|
+
conn = _FakeConnection()
|
|
599
|
+
PostgresAdapter(conn).ensure_table("s", "t", _KEYED, config={})
|
|
600
|
+
expected = pg_sql.SQL("CREATE TABLE IF NOT EXISTS {}.{} ({})").format(
|
|
601
|
+
pg_sql.Identifier("s"), pg_sql.Identifier("t"), adapter_module._column_ddl(_KEYED)
|
|
602
|
+
)
|
|
603
|
+
assert conn.cursors[-1].statements[1] == expected
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def test_ensure_table_rejects_a_primary_key_on_an_undeclared_column_and_emits_no_ddl():
|
|
607
|
+
conn = _FakeConnection()
|
|
608
|
+
with pytest.raises(ValueError, match=r"primary key on undeclared column\(s\) \['quote'\]"):
|
|
609
|
+
PostgresAdapter(conn).ensure_table(
|
|
610
|
+
"s", "t", _KEYED, config={}, primary_key=("rate_date", "quote")
|
|
611
|
+
)
|
|
612
|
+
assert conn.cursors == []
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def test_on_conflict_overwrites_every_non_key_column():
|
|
616
|
+
assert adapter_module._on_conflict(["rate_date", "base", "rate"], ["rate_date", "base"]) == (
|
|
617
|
+
pg_sql.SQL("ON CONFLICT ({}) DO UPDATE SET {}").format(
|
|
618
|
+
pg_sql.SQL(", ").join([pg_sql.Identifier("rate_date"), pg_sql.Identifier("base")]),
|
|
619
|
+
pg_sql.SQL(", ").join([
|
|
620
|
+
pg_sql.SQL("{} = EXCLUDED.{}").format(
|
|
621
|
+
pg_sql.Identifier("rate"), pg_sql.Identifier("rate")
|
|
622
|
+
)
|
|
623
|
+
]),
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def test_on_conflict_does_nothing_when_every_column_is_in_the_key():
|
|
629
|
+
assert adapter_module._on_conflict(["a", "b"], ["a", "b"]) == pg_sql.SQL(
|
|
630
|
+
"ON CONFLICT ({}) DO NOTHING"
|
|
631
|
+
).format(pg_sql.SQL(", ").join([pg_sql.Identifier("a"), pg_sql.Identifier("b")]))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def test_upsert_of_zero_rows_opens_no_transaction():
|
|
635
|
+
conn = _FakeConnection()
|
|
636
|
+
PostgresAdapter(conn).upsert("s", "t", pa.table({"id": pa.array([], pa.int64())}), ["id"])
|
|
637
|
+
assert conn.cursors == []
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The engine-contract conformance suite against postgres:16 (tests/smoke/postgres-stack).
|
|
2
|
+
|
|
3
|
+
Run the stack first:
|
|
4
|
+
docker compose -f tests/smoke/postgres-stack/docker-compose.yml up -d --wait
|
|
5
|
+
"""
|
|
6
|
+
import datetime
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
import psycopg2
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from continuo_engine_contract.testing.conformance import (
|
|
14
|
+
ARROW,
|
|
15
|
+
COLUMNS,
|
|
16
|
+
FX_COLUMNS,
|
|
17
|
+
NAMES,
|
|
18
|
+
AppendConformance,
|
|
19
|
+
ConformanceBase,
|
|
20
|
+
IntrospectionConformance,
|
|
21
|
+
UpsertConformance,
|
|
22
|
+
table_of,
|
|
23
|
+
)
|
|
24
|
+
from continuo_postgres_adapter.adapter import PostgresAdapter
|
|
25
|
+
|
|
26
|
+
pytestmark = pytest.mark.integration
|
|
27
|
+
|
|
28
|
+
PG = dict(
|
|
29
|
+
host="localhost",
|
|
30
|
+
port=os.environ.get("VR_IT_PG_PORT", "15499"),
|
|
31
|
+
dbname="warehouse",
|
|
32
|
+
user="continuo",
|
|
33
|
+
password="continuo",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def adapter():
|
|
39
|
+
connected = PostgresAdapter(psycopg2.connect(**PG))
|
|
40
|
+
yield connected
|
|
41
|
+
connected.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def schema(adapter):
|
|
46
|
+
name = f"conf_{uuid.uuid4().hex[:10]}"
|
|
47
|
+
yield name
|
|
48
|
+
adapter.drop_schema(name)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestPostgresIntrospection(IntrospectionConformance):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestPostgresAppend(AppendConformance):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestPostgresUpsert(UpsertConformance):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _primary_key_columns(schema: str, table: str) -> list[str]:
|
|
64
|
+
conn = psycopg2.connect(**PG)
|
|
65
|
+
try:
|
|
66
|
+
with conn.cursor() as cur:
|
|
67
|
+
cur.execute(
|
|
68
|
+
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
|
|
69
|
+
"JOIN information_schema.key_column_usage kcu "
|
|
70
|
+
"ON kcu.constraint_name = tc.constraint_name "
|
|
71
|
+
"AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name "
|
|
72
|
+
"WHERE tc.table_schema = %s AND tc.table_name = %s "
|
|
73
|
+
"AND tc.constraint_type = 'PRIMARY KEY' ORDER BY kcu.ordinal_position",
|
|
74
|
+
(schema, table),
|
|
75
|
+
)
|
|
76
|
+
return [row[0] for row in cur.fetchall()]
|
|
77
|
+
finally:
|
|
78
|
+
conn.close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_ensure_table_with_a_primary_key_creates_an_enforced_constraint(adapter, schema):
|
|
82
|
+
"""Review Focus 4: the key exists as soon as the first run created the table."""
|
|
83
|
+
adapter.ensure_table(schema, "keyed", FX_COLUMNS, config={}, primary_key=["rate_date", "base"])
|
|
84
|
+
assert _primary_key_columns(schema, "keyed") == ["rate_date", "base"]
|
|
85
|
+
conn = psycopg2.connect(**PG)
|
|
86
|
+
conn.autocommit = True
|
|
87
|
+
try:
|
|
88
|
+
with conn.cursor() as cur:
|
|
89
|
+
insert = f'INSERT INTO "{schema}"."keyed" VALUES (%s, %s, %s)'
|
|
90
|
+
cur.execute(insert, (datetime.date(2026, 10, 8), "EUR", None))
|
|
91
|
+
with pytest.raises(psycopg2.errors.UniqueViolation):
|
|
92
|
+
cur.execute(insert, (datetime.date(2026, 10, 8), "EUR", None))
|
|
93
|
+
finally:
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_upsert_into_a_table_without_its_primary_key_fails_and_writes_nothing(adapter, schema):
|
|
98
|
+
"""ON CONFLICT needs the constraint; postgres refuses instead of collecting duplicates."""
|
|
99
|
+
adapter.ensure_table(schema, "unkeyed", COLUMNS, config={})
|
|
100
|
+
adapter.append(schema, "unkeyed", table_of(ARROW, [(1, "a", 10)]))
|
|
101
|
+
with pytest.raises(psycopg2.errors.InvalidColumnReference):
|
|
102
|
+
adapter.upsert(schema, "unkeyed", table_of(ARROW, [(1, "b", 20)]), ["id"])
|
|
103
|
+
ConformanceBase().expect_rows(adapter, schema, "unkeyed", NAMES, [(1, "a", 10)])
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Run the stack first:
|
|
4
4
|
docker compose -f tests/smoke/postgres-stack/docker-compose.yml up -d --wait
|
|
5
5
|
"""
|
|
6
|
+
import datetime
|
|
6
7
|
import decimal
|
|
7
8
|
import os
|
|
8
9
|
import uuid
|
|
@@ -354,7 +355,7 @@ def test_run_node_csv_kind_loads_minio_csv_into_postgres(clean_schema, csv_minio
|
|
|
354
355
|
doubles here -- the S3CsvSourceReader from csv_readers.reader_for talks
|
|
355
356
|
to the real minio container, and PostgresAdapter writes to the real
|
|
356
357
|
postgres stack), then conform()/ensure_table()/load() proceed exactly as
|
|
357
|
-
for a python-
|
|
358
|
+
for a python-node node.
|
|
358
359
|
"""
|
|
359
360
|
monkeypatch.setenv("S3_ENDPOINT_URL", csv_minio)
|
|
360
361
|
repo = _csv_contract_dir(tmp_path, clean_schema)
|
|
@@ -382,3 +383,110 @@ def test_run_node_csv_kind_loads_minio_csv_into_postgres(clean_schema, csv_minio
|
|
|
382
383
|
rows = cur.fetchall()
|
|
383
384
|
conn.close()
|
|
384
385
|
assert rows == [(1, 10.5), (2, 20.0), (3, 5.25)]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --- write modes end to end ----------------------------------------------------
|
|
389
|
+
|
|
390
|
+
_FX_COLUMNS = [
|
|
391
|
+
{"name": "rate_date", "type": "DATE", "nullable": False},
|
|
392
|
+
{"name": "base", "type": "VARCHAR(3)", "nullable": False},
|
|
393
|
+
{"name": "rate", "type": "DECIMAL(12,6)", "nullable": True},
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
_FX_SCRIPT = (
|
|
397
|
+
"import datetime\n"
|
|
398
|
+
"from decimal import Decimal\n\n"
|
|
399
|
+
"import pyarrow as pa\n\n"
|
|
400
|
+
"ROWS = {rows!r}\n\n\n"
|
|
401
|
+
"def run(ctx):\n"
|
|
402
|
+
" return pa.table({{\n"
|
|
403
|
+
" 'rate_date': pa.array([datetime.date.fromisoformat(r[0]) for r in ROWS], pa.date32()),\n"
|
|
404
|
+
" 'base': pa.array([r[1] for r in ROWS], pa.string()),\n"
|
|
405
|
+
" 'rate': pa.array([Decimal(r[2]) for r in ROWS], pa.decimal128(12, 6)),\n"
|
|
406
|
+
" }})\n"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _fx_run_env(repo, schema, run, rows, **mode):
|
|
411
|
+
"""A python-api fx node returning *rows*, with a script file unique to *run*.
|
|
412
|
+
|
|
413
|
+
One file per run: rewriting the same file within a second at the same size
|
|
414
|
+
would let Python reuse its cached bytecode and return the previous rows.
|
|
415
|
+
"""
|
|
416
|
+
(repo / "contracts").mkdir(exist_ok=True)
|
|
417
|
+
(repo / "scripts").mkdir(exist_ok=True)
|
|
418
|
+
script = f"scripts/fx_{run}.py"
|
|
419
|
+
(repo / script).write_text(_FX_SCRIPT.format(rows=rows))
|
|
420
|
+
node = {
|
|
421
|
+
"schema": schema, "table": "fx_rates", "owner": "m", "schedule": "hourly",
|
|
422
|
+
"criticality": "SECONDARY", "kind": "python-api", "script": script,
|
|
423
|
+
"output_columns": _FX_COLUMNS, **mode,
|
|
424
|
+
}
|
|
425
|
+
(repo / "contracts" / "fx.yml").write_text(yaml.safe_dump({"nodes": [node]}))
|
|
426
|
+
return {
|
|
427
|
+
"NODE_ID": f"python-api.svc.{schema}.fx_rates", "TABLE_NAME": "fx_rates",
|
|
428
|
+
"TARGET_SCHEMA": schema, "CONTRACT_DIR": str(repo / "contracts"), "APP_ROOT": str(repo),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@pytest.mark.integration
|
|
433
|
+
def test_run_node_append_keeps_the_rows_of_earlier_runs(clean_schema, tmp_path):
|
|
434
|
+
env = _fx_run_env(tmp_path, clean_schema, "run1", [("2026-10-08", "EUR", "1.081200")], mode_type="append")
|
|
435
|
+
assert run_node(env, adapter=_adapter()) == 0
|
|
436
|
+
env = _fx_run_env(
|
|
437
|
+
tmp_path, clean_schema, "run2",
|
|
438
|
+
[("2026-10-08", "EUR", "1.081200"), ("2026-10-09", "EUR", "1.090000")],
|
|
439
|
+
mode_type="append",
|
|
440
|
+
)
|
|
441
|
+
assert run_node(env, adapter=_adapter()) == 0
|
|
442
|
+
assert _count(clean_schema, "fx_rates") == 3
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@pytest.mark.integration
|
|
446
|
+
def test_run_node_upsert_overwrites_by_key_and_keeps_the_constraint(clean_schema, tmp_path):
|
|
447
|
+
"""Review Focus 4 and 5 through the whole harness."""
|
|
448
|
+
key = {"mode_type": "upsert", "primary_key": ["rate_date", "base"]}
|
|
449
|
+
env = _fx_run_env(
|
|
450
|
+
tmp_path, clean_schema, "run1",
|
|
451
|
+
[("2026-10-08", "EUR", "1.081200"), ("2026-10-08", "GBP", "0.861000")], **key,
|
|
452
|
+
)
|
|
453
|
+
assert run_node(env, adapter=_adapter()) == 0
|
|
454
|
+
env = _fx_run_env(
|
|
455
|
+
tmp_path, clean_schema, "run2",
|
|
456
|
+
[("2026-10-08", "EUR", "1.090000"), ("2026-10-09", "EUR", "1.100000")], **key,
|
|
457
|
+
)
|
|
458
|
+
assert run_node(env, adapter=_adapter()) == 0
|
|
459
|
+
conn = _conn()
|
|
460
|
+
with conn.cursor() as cur:
|
|
461
|
+
cur.execute(
|
|
462
|
+
f'SELECT rate_date, base, rate FROM "{clean_schema}"."fx_rates" ORDER BY rate_date, base'
|
|
463
|
+
)
|
|
464
|
+
rows = cur.fetchall()
|
|
465
|
+
cur.execute(
|
|
466
|
+
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
|
|
467
|
+
"JOIN information_schema.key_column_usage kcu "
|
|
468
|
+
"ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema "
|
|
469
|
+
"AND kcu.table_name = tc.table_name "
|
|
470
|
+
"WHERE tc.table_schema = %s AND tc.table_name = %s "
|
|
471
|
+
"AND tc.constraint_type = 'PRIMARY KEY' ORDER BY kcu.ordinal_position",
|
|
472
|
+
(clean_schema, "fx_rates"),
|
|
473
|
+
)
|
|
474
|
+
key_columns = [row[0] for row in cur.fetchall()]
|
|
475
|
+
conn.close()
|
|
476
|
+
assert rows == [
|
|
477
|
+
(datetime.date(2026, 10, 8), "EUR", decimal.Decimal("1.090000")),
|
|
478
|
+
(datetime.date(2026, 10, 8), "GBP", decimal.Decimal("0.861000")),
|
|
479
|
+
(datetime.date(2026, 10, 9), "EUR", decimal.Decimal("1.100000")),
|
|
480
|
+
]
|
|
481
|
+
assert key_columns == ["rate_date", "base"]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@pytest.mark.integration
|
|
485
|
+
def test_run_node_append_into_a_drifted_table_fails_naming_the_column(clean_schema, tmp_path, capsys):
|
|
486
|
+
_seed(clean_schema, "fx_rates", "rate_date date NOT NULL, base varchar(3) NOT NULL", "")
|
|
487
|
+
env = _fx_run_env(tmp_path, clean_schema, "run1", [("2026-10-08", "EUR", "1.081200")], mode_type="append")
|
|
488
|
+
assert run_node(env, adapter=_adapter()) == 1
|
|
489
|
+
out = capsys.readouterr().out
|
|
490
|
+
assert '"message":"LoadError: table runtime_it.fx_rates does not match' in out
|
|
491
|
+
assert "missing column(s) ['rate']" in out
|
|
492
|
+
assert _count(clean_schema, "fx_rates") == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|