Skip to content

Commit 6779f08

Browse files
authored
feat: parallel testing infrastructure and PostgreSQL backend improvements (#55)
* feat: parallel scenario tests with per-worker database pooling Wire the testsuite database pool for postgres: register the pool reset handler, make scenario resolution pool-aware, install core and testsuite from the feature/parallel-testing branches, and run tests with -n 8. * fix(test): load testsuite conftest plugin and guard --scenarios registration Without -p rhosocial.activerecord.testsuite.conftest the pooling hooks never run, so configure_pool_size stays 0 and every xdist worker falls back to the shared scenario database, racing on schema DDL. Load the plugin and guard the backend's own --scenarios option so both conftests coexist. * fix(test): apply pooled database names in backend and connection feature conftests * fix(test): apply pooled database names in worker connection params * fix(test): check for simultaneous double-hold instead of id uniqueness * fix(test): use a non-empty LSN range for pg_get_wal_records_info * feat: CLI async unification, --ssl param; postgres backend fixes * feat(postgres): add named expression/procedure example modules - Add named_expressions order_expressions (import-safe, prepare_orders_demo helper) - Add named_procedures order_workflow (OrderProcessingProcedure, max_concurrency=1 for psycopg single-connection concurrency limit) - Update bash scripts to reference real module paths instead of placeholders and full module paths for --list (package path returns no entries) * test(expression): add serialization round-trip coverage for PostgreSQL expressions * fix(dialect): translate is_auto_increment to GENERATED BY DEFAULT AS IDENTITY ColumnConstraint(is_auto_increment=True) on PRIMARY_KEY was silently ignored, producing INTEGER PRIMARY KEY without auto-increment. PostgreSQL requires explicit GENERATED BY DEFAULT AS IDENTITY for auto-increment columns. The existing dialect_options['identity'] path is unaffected. * test: add is_auto_increment/DDL regression tests Dialect-level tests (no DB needed) verifying: 1. is_auto_increment=True produces correct auto-increment DDL keyword 2. All standard types (Integer/Text/Boolean/VarChar/Timestamp) compile 3. BooleanType + DEFAULT True is accepted 4. TimestampType maps to correct backend-specific type Prevents recurrence of bugs found in rbac cross-backend testing. * fix(config): declare missing autocommit field on PostgresConnectionConfig The backend read config.autocommit (requires_manual_commit and the auto-commit handlers in sync/async backends) to decide whether an explicit COMMIT is needed outside managed transactions, but the field did not exist on the dataclass, so passing autocommit=True raised a TypeError and the option was effectively dead. All other backends (mysql, sqlserver, clickhouse, snowflake, firebird) already declare it. Replace the defensive getattr() fallbacks with direct attribute access and add unit tests covering the default value, explicit assignment, conninfo exclusion, and requires_manual_commit() for both backends. * test: cover column qualifier resolution under JOINs Regression tests for qualifier generation vs FROM context: plain field accessors, mixed/partial aliases, prebuilt-predicate reuse across contexts, explicit-alias joins, and an xfail sentinel documenting the accepted dynamic __table_name__ drift limitation. Companion to the python-activerecord fix that stops forcing table-name aliases on JOIN ranges. * test: cover dynamic table/schema names as supported usage Replace the R4 xfail sentinel with positive tests for the documented contract: after changing __table_name__ / __schema_name__, expressions built through field proxies pick up the new identity, and full DQL stays consistent across FROM / WHERE / SELECT. Seed an archive table and a second tenant-style schema so both switch directions assert real data. * feat(test): provide cross-schema fixtures for testsuite Implements the optional setup_schema_fixtures capability: provisions ar_crm / ar_shop schemas with customers and orders tables (sync + async variants; DDL runs through a short-lived sync connection for async), tracks created schemas, and drops them in cleanup_after_test. Declares supports_schema() == True. * feat(dialect): declare supports_schema capability and schema DDL tests * fix(protocols): declare supports_schema and supports_schema_authorization in PostgresSchemaSupport * test(provider): provision mixed default/non-default schema fixtures * test(adapters): offline adapter round-trip coverage for all types * docs: record stateflow cross-backend test issues (JSON/bool/RETURNING) * fix(hstore): pass raw % operator to format_binary_operator to avoid double escaping hstore_to_array_operator passed the already-escaped '%%' operator to format_binary_operator, which escapes '%' again to '%%%%' (psycopg folds to '%%' instead of the expected '%'). Pass the raw '%' so format_binary_operator performs the single %->%% escape as the sole collection point. * fix(adapters): parse array literals on read for list fields in TEXT columns PostgresListAdapter now parses JSON and PostgreSQL array literals via psycopg's array loader when reading list fields stored in TEXT columns, fixing the cross-backend round-trip exposed by stateflow tests. * test: add format_binary_operator % escaping and hstore operator tests * ci: update testsuite branch to release/v1.0.0.dev17 * ci: update rhosocial-activerecord branch to release/v1.0.0.dev30 * ci: update testsuite branch to release/v1.0.0.dev17 * feat(postgres): add pgvector vector_search function and expression type support - Add vector_search.py function module with pgvector search capabilities - Extend expression types with pgvector-related serialization - Add data_type_formatting mixin improvements - Add vector search and example compile test coverage * test: add targeted coverage for low-coverage patch areas - extend PostgresListAdapter string parsing tests (JSON + PG array literal) - add HALFVEC/SPARSEVEC DDL format/parse and eq/hash coverage - cover vector_search edge cases (ip similarity, index options, invalid metric) - add _get_default_schema config-aware resolution tests for both introspection stacks - cover backend autocommit handler branches (sync + async, True/False) - add CLI --ssl mapping and --async flag tests - add CLI blackbox tests for introspect/status --async fix(cli): correct async introspect method names (remove _async suffix) The _handle_introspect_async path called nonexistent list_tables_async, list_views_async etc. Async introspector methods use the same names as sync (no _async suffix). Fixes broken async introspect branch. * fix(test): tolerate empty table list in introspect blackbox tests CI runs against a fresh test_db where 'introspect tables' returns [], so assert json.loads(out) (truthy) fails. Assert the parsed JSON is a list instead, which holds whether or not tables exist. * chore: add changelog fragment for PR #55 * docs: remove non-standard changelog fragment The file name stateflow-cross-backend-issues.fixed.md does not follow the <issue_number>.<type>.md convention. Its content was already merged into the relevant fix commit.
1 parent beb040a commit 6779f08

61 files changed

Lines changed: 2824 additions & 569 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test.yml‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ jobs:
6868
6969
- name: Install activerecord and testsuite
7070
run: |
71-
pip install rhosocial-activerecord==1.0.0.dev29
72-
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
71+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
72+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
7373
7474
- name: Create postgres scenario config
7575
run: |
@@ -279,8 +279,8 @@ jobs:
279279
280280
- name: Install activerecord and testsuite
281281
run: |
282-
pip install rhosocial-activerecord==1.0.0.dev29
283-
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
282+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
283+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
284284
285285
- name: Create postgres scenario config
286286
run: |
@@ -322,7 +322,7 @@ jobs:
322322
- name: Run tests
323323
run: |
324324
export PYTHONPATH=src:tests
325-
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup
325+
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup
326326
327327
test:
328328
name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }}
@@ -391,8 +391,8 @@ jobs:
391391
392392
- name: Install activerecord and testsuite
393393
run: |
394-
pip install rhosocial-activerecord==1.0.0.dev29
395-
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
394+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
395+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
396396
397397
- name: Create postgres scenario config
398398
run: |
@@ -435,14 +435,14 @@ jobs:
435435
if: matrix.postgres-version != '18'
436436
run: |
437437
export PYTHONPATH=src:tests
438-
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup
438+
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup
439439
440440
- name: Run tests with coverage (only for postgres 18)
441441
if: matrix.postgres-version == '18'
442442
run: |
443443
export PYTHONPATH=src:tests
444444
pip install codecov
445-
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup --cov=src --cov-report=xml
445+
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup --cov=src --cov-report=xml
446446
447447
- name: Upload coverage to Codecov
448448
if: matrix.postgres-version == '18'
@@ -490,8 +490,8 @@ jobs:
490490
491491
- name: Install activerecord and testsuite
492492
run: |
493-
pip install rhosocial-activerecord==1.0.0.dev29
494-
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
493+
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
494+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
495495
496496
- name: Create postgres scenario config
497497
run: |
@@ -522,4 +522,4 @@ jobs:
522522
- name: Run tests
523523
run: |
524524
export PYTHONPATH=src:tests
525-
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup
525+
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup

‎changelog.d/55.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add parallel testing infrastructure and PostgreSQL backend improvements

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ rhosocial = ["**/*.pyi", "**/*.py"]
7979

8080
[tool.pytest.ini_options]
8181
minversion = "7.0"
82-
addopts = "-ra -q"
82+
addopts = "-ra -q -p rhosocial.activerecord.testsuite.conftest"
8383
asyncio_mode = "auto"
8484
asyncio_default_fixture_loop_scope = "function"
8585
testpaths = [

‎src/rhosocial/activerecord/backend/impl/postgres/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ python -m rhosocial.activerecord.backend.impl.postgres [OPTIONAL_FLAGS] [YOUR_SQ
3030
| `--database` | `POSTGRES_DATABASE` env var or _None_ | Database name to connect to. |
3131
| `--user` | `POSTGRES_USER` env var or `postgres` | Database user. |
3232
| `--password` | `POSTGRES_PASSWORD` env var or _empty string_ | Database password. |
33-
| `--use-async` | _False_ | Use the asynchronous backend. |
33+
| `--async` | _False_ | Use the asynchronous backend. |
3434
| `--output` | `table` (if rich available) or `json` (fallback) | Output format. Choices are `table` (rich formatted), `json`, `csv`, `tsv`. |
3535
| `--log-level` | `INFO` | Set the logging level (e.g., `DEBUG`, `INFO`). |
3636
| `--rich-ascii` | _False_ | Use ASCII characters for table borders. |

‎src/rhosocial/activerecord/backend/impl/postgres/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@
6767
PostgresUUIDType,
6868
PostgresVarBitType,
6969
PostgresVectorType,
70+
PostgresHalfvecType,
71+
PostgresSparsevecType,
7072
PostgresXID8Type,
7173
PostgresXIDType,
7274
PostgresXMLType,
@@ -598,6 +600,8 @@
598600
"PostgresUUIDType",
599601
"PostgresVarBitType",
600602
"PostgresVectorType",
603+
"PostgresHalfvecType",
604+
"PostgresSparsevecType",
601605
"PostgresXID8Type",
602606
"PostgresXIDType",
603607
"PostgresXMLType",

‎src/rhosocial/activerecord/backend/impl/postgres/__main__.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,18 @@ def _build_parser():
2323
return parser
2424

2525

26-
def parse_args():
26+
def parse_args(argv=None):
2727
"""Parse command-line arguments.
2828
2929
Provided for test compatibility. In production, main() is used instead.
3030
"""
3131
parser = _build_parser()
32-
return parser.parse_args()
32+
return parser.parse_args(argv)
3333

3434

35-
def main():
35+
def main(argv=None):
3636
parser = _build_parser()
37-
args = parser.parse_args()
37+
args = parser.parse_args(argv)
3838

3939
if args.command is None:
4040
cmd_list = ", ".join(f"'{c}'" for c in COMMAND_NAMES[:-1])

‎src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,59 @@ class PostgresListAdapter(BaseSQLTypeAdapter):
1818
"""
1919
Adapts Python list to PostgreSQL array types.
2020
21-
This adapter does not perform any conversion - psycopg handles
22-
Python lists natively for PostgreSQL array types.
21+
This adapter passes Python lists through to psycopg for native array
22+
column support. On read, if the database returns a string (e.g. when
23+
a list was stored in a TEXT column via psycopg array serialization),
24+
it parses the value back using psycopg's own array loader — the exact
25+
mirror of the ``ListDumper`` used on write — and falls back to JSON
26+
for the portable ``["a","b"]`` representation.
2327
"""
2428

2529
def __init__(self):
2630
super().__init__()
2731
self._register_type(list, list)
2832

2933
def _do_to_database(self, value: list, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any:
30-
# psycopg handles list natively for PostgreSQL arrays
3134
return value
3235

36+
@staticmethod
37+
def _parse_array_literal(value: str) -> list:
38+
"""Parse a PostgreSQL array literal using psycopg's array loader.
39+
40+
``psycopg.types.array._load_text`` is the exact reverse of the
41+
``ListDumper`` that psycopg uses to serialize a Python list on
42+
write, so the round-trip is symmetric and handles quoting,
43+
backslash escaping, ``NULL`` elements and nested arrays correctly.
44+
"""
45+
from psycopg.types.array import _load_text
46+
47+
class _TextElementLoader:
48+
def load(self, data):
49+
return data.decode("utf-8")
50+
51+
try:
52+
return _load_text(value.encode("utf-8"), _TextElementLoader())
53+
except Exception:
54+
raise ValueError(f"Not a PostgreSQL array literal: {value!r}") from None
55+
3356
def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> list:
3457
if isinstance(value, list):
3558
return value
59+
if isinstance(value, str):
60+
stripped = value.strip()
61+
# Try JSON first (more portable format)
62+
if stripped.startswith("["):
63+
try:
64+
return json.loads(stripped)
65+
except (json.JSONDecodeError, ValueError):
66+
pass
67+
# Try PostgreSQL array literal
68+
if stripped.startswith("{"):
69+
try:
70+
return self._parse_array_literal(stripped)
71+
except (ValueError, IndexError):
72+
pass
73+
raise TypeError(f"Cannot convert str to list: {value!r}")
3674
raise TypeError(f"Cannot convert {type(value).__name__} to list")
3775

3876
def to_database_batch(

‎src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ async def _handle_auto_commit(self) -> None:
644644
# Check if we're not in an active transaction
645645
if not self.in_transaction:
646646
# For PostgreSQL, if autocommit is disabled, we need to commit explicitly
647-
if not getattr(self.config, "autocommit", False):
647+
if not self.config.autocommit:
648648
await self._connection.commit()
649649
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")
650650
except Exception as e:
@@ -658,7 +658,7 @@ async def _handle_auto_commit_if_needed(self) -> None:
658658
PostgreSQL respects the autocommit setting, but we also need to handle explicit commits.
659659
"""
660660
if not self.in_transaction and self._connection:
661-
if not getattr(self.config, "autocommit", False):
661+
if not self.config.autocommit:
662662
await self._connection.commit()
663663
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")
664664

‎src/rhosocial/activerecord/backend/impl/postgres/backend/base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def threadsafety(self) -> int:
8282

8383
def requires_manual_commit(self) -> bool:
8484
"""Check if manual commit is required for this database."""
85-
return not getattr(self.config, "autocommit", False)
85+
return not self.config.autocommit
8686

8787
def _register_postgres_adapters(self):
8888
"""Register PostgreSQL-specific type adapters.

‎src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ def _handle_auto_commit(self) -> None:
645645
# Check if we're not in an active transaction
646646
if not self.in_transaction:
647647
# For PostgreSQL, if autocommit is disabled, we need to commit explicitly
648-
if not getattr(self.config, "autocommit", False):
648+
if not self.config.autocommit:
649649
self._connection.commit()
650650
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")
651651
except Exception as e:
@@ -659,7 +659,7 @@ def _handle_auto_commit_if_needed(self) -> None:
659659
PostgreSQL respects the autocommit setting, but we also need to handle explicit commits.
660660
"""
661661
if not self.in_transaction and self._connection:
662-
if not getattr(self.config, "autocommit", False):
662+
if not self.config.autocommit:
663663
self._connection.commit()
664664
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")
665665

0 commit comments

Comments
 (0)