continuo-postgres-adapter 0.2.2__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.
@@ -5,6 +5,7 @@ __pycache__/
5
5
  dist/
6
6
  .superpowers/
7
7
  docs/superpowers/
8
+ docs/ddd-violations/
8
9
  .pytest_cache/
9
10
  .mypy_cache/
10
11
  .ruff_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: continuo-postgres-adapter
3
- Version: 0.2.2
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.7.3
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``/``load`` build and atomically
12
- replace a table's contents.
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 WarehouseAdapter # type: ignore[import-untyped]
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
- indexes = _validated_indexes(config, table, [col["name"] for col in columns])
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
- _column_ddl(columns),
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
- def load(self, schema: str, table: str, data: "pa.Table") -> None:
654
- """Atomically replace ``schema.table``'s contents with *data*.
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
- One explicit transaction: TRUNCATE then batched inserts
657
- (``execute_values``, page_size 1000) in the Arrow table's column
658
- order; commits on success, rolls back and re-raises on any error. A
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.execute(
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("load rollback failed: %s", rollback_exc)
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.2"
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.7.3",
15
+ "continuo-engine-contract==0.8.0",
16
16
  "psycopg2-binary==2.9.12",
17
17
  "pyarrow==25.0.1",
18
18
  ]
@@ -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
@@ -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