Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5c113bc
feat: parallel scenario tests with per-worker database pooling
vistart Aug 18, 2026
c8215b0
fix(test): load testsuite conftest plugin and guard --scenarios regis…
vistart Aug 18, 2026
05e05d1
fix(test): apply pooled database names in backend and connection feat…
vistart Aug 18, 2026
cfc5b10
fix(test): apply pooled database names in worker connection params
vistart Aug 18, 2026
916ad44
fix(test): check for simultaneous double-hold instead of id uniqueness
vistart Aug 18, 2026
3c38e48
fix(test): use a non-empty LSN range for pg_get_wal_records_info
vistart Aug 18, 2026
9e16487
feat: CLI async unification, --ssl param; postgres backend fixes
vistart Aug 18, 2026
1f0d955
feat(postgres): add named expression/procedure example modules
vistart Aug 19, 2026
5492540
test(expression): add serialization round-trip coverage for PostgreSQ…
vistart Aug 24, 2026
1b24c37
fix(dialect): translate is_auto_increment to GENERATED BY DEFAULT AS …
vistart Aug 25, 2026
971b7fe
test: add is_auto_increment/DDL regression tests
vistart Aug 25, 2026
3efcf2a
fix(config): declare missing autocommit field on PostgresConnectionCo…
vistart Aug 25, 2026
febfb3d
test: cover column qualifier resolution under JOINs
vistart Aug 25, 2026
060b942
test: cover dynamic table/schema names as supported usage
vistart Aug 25, 2026
0db66f1
feat(test): provide cross-schema fixtures for testsuite
vistart Aug 25, 2026
1c6e946
feat(dialect): declare supports_schema capability and schema DDL tests
vistart Aug 26, 2026
e4dcc21
fix(protocols): declare supports_schema and supports_schema_authoriza…
vistart Aug 26, 2026
97fe491
test(provider): provision mixed default/non-default schema fixtures
vistart Aug 26, 2026
42ed194
test(adapters): offline adapter round-trip coverage for all types
vistart Aug 26, 2026
58c2f9c
docs: record stateflow cross-backend test issues (JSON/bool/RETURNING)
vistart Aug 27, 2026
3429ecb
fix(hstore): pass raw % operator to format_binary_operator to avoid d…
vistart Aug 27, 2026
ea74880
fix(adapters): parse array literals on read for list fields in TEXT c…
vistart Aug 27, 2026
9fc18f7
test: add format_binary_operator % escaping and hstore operator tests
vistart Aug 27, 2026
5ea6b36
ci: update testsuite branch to release/v1.0.0.dev17
vistart Aug 28, 2026
423c693
ci: update rhosocial-activerecord branch to release/v1.0.0.dev30
vistart Aug 28, 2026
cbc5c38
ci: update testsuite branch to release/v1.0.0.dev17
vistart Aug 28, 2026
03496d4
feat(postgres): add pgvector vector_search function and expression ty…
vistart Aug 28, 2026
f4c8a52
test: add targeted coverage for low-coverage patch areas
vistart Aug 28, 2026
3b04e9a
fix(test): tolerate empty table list in introspect blackbox tests
vistart Aug 28, 2026
c29da9f
chore: add changelog fragment for PR #55
vistart Aug 28, 2026
7137cce
docs: remove non-standard changelog fragment
vistart Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install rhosocial-activerecord==1.0.0.dev29
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -279,8 +279,8 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install rhosocial-activerecord==1.0.0.dev29
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -322,7 +322,7 @@ jobs:
- name: Run tests
run: |
export PYTHONPATH=src:tests
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup

test:
name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }}
Expand Down Expand Up @@ -391,8 +391,8 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install rhosocial-activerecord==1.0.0.dev29
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -435,14 +435,14 @@ jobs:
if: matrix.postgres-version != '18'
run: |
export PYTHONPATH=src:tests
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup

- name: Run tests with coverage (only for postgres 18)
if: matrix.postgres-version == '18'
run: |
export PYTHONPATH=src:tests
pip install codecov
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup --cov=src --cov-report=xml
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup --cov=src --cov-report=xml

- name: Upload coverage to Codecov
if: matrix.postgres-version == '18'
Expand Down Expand Up @@ -490,8 +490,8 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install rhosocial-activerecord==1.0.0.dev29
pip install rhosocial-activerecord-testsuite==1.0.0.dev16
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -522,4 +522,4 @@ jobs:
- name: Run tests
run: |
export PYTHONPATH=src:tests
python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup
1 change: 1 addition & 0 deletions changelog.d/55.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add parallel testing infrastructure and PostgreSQL backend improvements
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ rhosocial = ["**/*.pyi", "**/*.py"]

[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q"
addopts = "-ra -q -p rhosocial.activerecord.testsuite.conftest"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ python -m rhosocial.activerecord.backend.impl.postgres [OPTIONAL_FLAGS] [YOUR_SQ
| `--database` | `POSTGRES_DATABASE` env var or _None_ | Database name to connect to. |
| `--user` | `POSTGRES_USER` env var or `postgres` | Database user. |
| `--password` | `POSTGRES_PASSWORD` env var or _empty string_ | Database password. |
| `--use-async` | _False_ | Use the asynchronous backend. |
| `--async` | _False_ | Use the asynchronous backend. |
| `--output` | `table` (if rich available) or `json` (fallback) | Output format. Choices are `table` (rich formatted), `json`, `csv`, `tsv`. |
| `--log-level` | `INFO` | Set the logging level (e.g., `DEBUG`, `INFO`). |
| `--rich-ascii` | _False_ | Use ASCII characters for table borders. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
PostgresUUIDType,
PostgresVarBitType,
PostgresVectorType,
PostgresHalfvecType,
PostgresSparsevecType,
PostgresXID8Type,
PostgresXIDType,
PostgresXMLType,
Expand Down Expand Up @@ -598,6 +600,8 @@
"PostgresUUIDType",
"PostgresVarBitType",
"PostgresVectorType",
"PostgresHalfvecType",
"PostgresSparsevecType",
"PostgresXID8Type",
"PostgresXIDType",
"PostgresXMLType",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ def _build_parser():
return parser


def parse_args():
def parse_args(argv=None):
"""Parse command-line arguments.

Provided for test compatibility. In production, main() is used instead.
"""
parser = _build_parser()
return parser.parse_args()
return parser.parse_args(argv)


def main():
def main(argv=None):
parser = _build_parser()
args = parser.parse_args()
args = parser.parse_args(argv)

if args.command is None:
cmd_list = ", ".join(f"'{c}'" for c in COMMAND_NAMES[:-1])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,59 @@ class PostgresListAdapter(BaseSQLTypeAdapter):
"""
Adapts Python list to PostgreSQL array types.

This adapter does not perform any conversion - psycopg handles
Python lists natively for PostgreSQL array types.
This adapter passes Python lists through to psycopg for native array
column support. On read, if the database returns a string (e.g. when
a list was stored in a TEXT column via psycopg array serialization),
it parses the value back using psycopg's own array loader — the exact
mirror of the ``ListDumper`` used on write — and falls back to JSON
for the portable ``["a","b"]`` representation.
"""

def __init__(self):
super().__init__()
self._register_type(list, list)

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

@staticmethod
def _parse_array_literal(value: str) -> list:
"""Parse a PostgreSQL array literal using psycopg's array loader.

``psycopg.types.array._load_text`` is the exact reverse of the
``ListDumper`` that psycopg uses to serialize a Python list on
write, so the round-trip is symmetric and handles quoting,
backslash escaping, ``NULL`` elements and nested arrays correctly.
"""
from psycopg.types.array import _load_text

class _TextElementLoader:
def load(self, data):
return data.decode("utf-8")

try:
return _load_text(value.encode("utf-8"), _TextElementLoader())
except Exception:
raise ValueError(f"Not a PostgreSQL array literal: {value!r}") from None

def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> list:
if isinstance(value, list):
return value
if isinstance(value, str):
stripped = value.strip()
# Try JSON first (more portable format)
if stripped.startswith("["):
try:
return json.loads(stripped)
except (json.JSONDecodeError, ValueError):
pass
# Try PostgreSQL array literal
if stripped.startswith("{"):
try:
return self._parse_array_literal(stripped)
except (ValueError, IndexError):
pass
raise TypeError(f"Cannot convert str to list: {value!r}")
raise TypeError(f"Cannot convert {type(value).__name__} to list")

def to_database_batch(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ async def _handle_auto_commit(self) -> None:
# Check if we're not in an active transaction
if not self.in_transaction:
# For PostgreSQL, if autocommit is disabled, we need to commit explicitly
if not getattr(self.config, "autocommit", False):
if not self.config.autocommit:
await self._connection.commit()
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")
except Exception as e:
Expand All @@ -658,7 +658,7 @@ async def _handle_auto_commit_if_needed(self) -> None:
PostgreSQL respects the autocommit setting, but we also need to handle explicit commits.
"""
if not self.in_transaction and self._connection:
if not getattr(self.config, "autocommit", False):
if not self.config.autocommit:
await self._connection.commit()
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def threadsafety(self) -> int:

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

def _register_postgres_adapters(self):
"""Register PostgreSQL-specific type adapters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ def _handle_auto_commit(self) -> None:
# Check if we're not in an active transaction
if not self.in_transaction:
# For PostgreSQL, if autocommit is disabled, we need to commit explicitly
if not getattr(self.config, "autocommit", False):
if not self.config.autocommit:
self._connection.commit()
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")
except Exception as e:
Expand All @@ -659,7 +659,7 @@ def _handle_auto_commit_if_needed(self) -> None:
PostgreSQL respects the autocommit setting, but we also need to handle explicit commits.
"""
if not self.in_transaction and self._connection:
if not getattr(self.config, "autocommit", False):
if not self.config.autocommit:
self._connection.commit()
self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,15 @@ def add_connection_args(parser):
help="Database password (env: POSTGRES_PASSWORD)",
)
parser.add_argument(
"--use-async",
"--ssl",
choices=["auto", "require", "verify-ca", "verify-full", "disabled"],
default="auto",
help="SSL mode (env: POSTGRES_SSL, default: auto)",
)
parser.add_argument(
"--async",
action="store_true",
dest="is_async",
help="Use asynchronous backend",
)
parser.add_argument(
Expand Down Expand Up @@ -118,12 +125,24 @@ def resolve_connection_config_from_args(args):
return resolver.resolve({})

# Fallback to explicit connection parameters
# SSL parameter mapping (simplified for CLI unification)
ssl_param = getattr(args, "ssl", None)
sslmode_map = {
"auto": "prefer",
"require": "require",
"verify-ca": "verify-ca",
"verify-full": "verify-full",
"disabled": "disable",
}
sslmode = sslmode_map.get(ssl_param, "prefer")

return PostgresConnectionConfig(
host=args.host or "localhost",
port=args.port or 5432,
database=args.database,
username=args.user,
password=args.password,
sslmode=sslmode,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def handle(args):

config = resolve_connection_config_from_args(args)

if args.use_async:
if args.is_async:
backend = AsyncPostgresBackend(connection_config=config)
asyncio.run(_handle_introspect_async(args, backend, provider))
else:
Expand Down Expand Up @@ -280,23 +280,23 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider
introspector = backend.introspector

if args.type == "tables":
tables = await introspector.list_tables_async(
tables = await introspector.list_tables(
schema=args.schema,
include_system=args.include_system
)
data = _serialize_for_output(tables)
provider.display_results(data, title="Tables")

elif args.type == "views":
views = await introspector.list_views_async(schema=args.schema)
views = await introspector.list_views(schema=args.schema)
data = _serialize_for_output(views)
provider.display_results(data, title="Views")

elif args.type == "table":
if not args.name:
print("Error: Table name is required for 'table' introspection", file=sys.stderr)
sys.exit(1)
info = await introspector.get_table_info_async(args.name, schema=args.schema)
info = await introspector.get_table_info(args.name, schema=args.schema)
if info:
provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}")
if info.indexes:
Expand All @@ -314,36 +314,36 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider
if not args.name:
print("Error: Table name is required for 'columns' introspection", file=sys.stderr)
sys.exit(1)
columns = await introspector.list_columns_async(args.name, schema=args.schema)
columns = await introspector.list_columns(args.name, schema=args.schema)
data = _serialize_for_output(columns)
provider.display_results(data, title=f"Columns of {args.name}")

elif args.type == "indexes":
if not args.name:
print("Error: Table name is required for 'indexes' introspection", file=sys.stderr)
sys.exit(1)
indexes = await introspector.list_indexes_async(args.name, schema=args.schema)
indexes = await introspector.list_indexes(args.name, schema=args.schema)
data = _serialize_for_output(indexes)
provider.display_results(data, title=f"Indexes of {args.name}")

elif args.type == "foreign-keys":
if not args.name:
print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr)
sys.exit(1)
fks = await introspector.list_foreign_keys_async(args.name, schema=args.schema)
fks = await introspector.list_foreign_keys(args.name, schema=args.schema)
data = _serialize_for_output(fks)
provider.display_results(data, title=f"Foreign Keys of {args.name}")

elif args.type == "triggers":
triggers = await introspector.list_triggers_async(
triggers = await introspector.list_triggers(
table_name=args.name,
schema=args.schema
)
data = _serialize_for_output(triggers)
provider.display_results(data, title="Triggers")

elif args.type == "database":
info = await introspector.get_database_info_async()
info = await introspector.get_database_info()
data = _serialize_for_output(info)
provider.display_results([data], title="Database Info")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def handle(args):
config = resolve_connection_config_from_args(args)
kwargs = {"use_ascii": args.rich_ascii}

if args.use_async:
if args.is_async:
backend = AsyncPostgresBackend(connection_config=config)
asyncio.run(_execute_query_async(sql_source, backend, provider, **kwargs))
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def handle(args):

config = resolve_connection_config_from_args(args)

if args.use_async:
if args.is_async:
backend = AsyncPostgresBackend(connection_config=config)
asyncio.run(_handle_status_async(args, backend, provider))
else:
Expand Down
5 changes: 5 additions & 0 deletions src/rhosocial/activerecord/backend/impl/postgres/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ class PostgresConnectionMixin:
service: Optional[str] = None
gssencmode: Optional[str] = None
channel_binding: Optional[str] = None
# Transaction behavior. The psycopg connection always runs with driver-level
# autocommit enabled and transactions are managed explicitly by the framework.
# When False (default), the backend issues an explicit COMMIT after statements
# executed outside a managed transaction; when True, those commits are skipped.
autocommit: bool = False
# Schema namespace support
search_path: Optional[str] = None # Set connection's search_path (passed directly to psycopg)
default_schema: Optional[str] = None # Default schema when Model doesn't specify __schema_name__
Expand Down
Loading
Loading