Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
beb040a
chore: bump version to 1.0.0.dev17
vistart Aug 16, 2026
6779f08
feat: parallel testing infrastructure and PostgreSQL backend improvem…
vistart Aug 28, 2026
cbd3cf2
feat(postgres): adapt to node-based expression system and declarative…
vistart Sep 10, 2026
f0be87f
ci: test against Python 3.15.0-rc.2
vistart Sep 10, 2026
95abef2
fix: Rename JoinExpression→JoinClause and format_grouping_expression→…
vistart Sep 11, 2026
2fae3ae
feat(dialect-protocol): formalize PostgreSQL type support and suggest…
vistart Sep 12, 2026
9df24de
fix(test): VarCharType dialect-first call sites in fixtures and examples
vistart Sep 12, 2026
15dfa99
fix(test): ArrayType/DecimalType/TimestampType dialect-first call sites
vistart Sep 12, 2026
cfbfcb0
fix(test): TimestampType precision keyword, ArrayType element_type ke…
vistart Sep 12, 2026
3829218
fix(test): hash tests use (type, _type_params) tuple
vistart Sep 12, 2026
381ac53
fix(test): ArrayType dimensions keyword in test_core_type_mappings
vistart Sep 12, 2026
b820f74
fix(examples): adapt example code to dialect-first ColumnDefinition/D…
vistart Sep 12, 2026
7da14ea
test(protocol): add negative protocol list + partition completeness
vistart Sep 12, 2026
31ee182
feat(dialect): declare AutoIncrement + GeneratedColumn protocol support
vistart Sep 12, 2026
400ee4c
fix(dialect): adapt format_fulltext_match/array_expression to express…
vistart Sep 13, 2026
f0245a2
feat(identifier): add reserved words and opt-out identifier quoting
vistart Sep 13, 2026
c505226
refactor(identifier): remove IdentifierMixin and fix format_column wi…
vistart Sep 13, 2026
d7d78ce
refactor(postgres): remove 11 duplicate dialect methods
vistart Sep 13, 2026
dcd433e
fix(mixins): restore Postgres EXPLAIN and TRUNCATE formatters
vistart Sep 13, 2026
4f11a48
fix(protocols): declare Postgres EXPLAIN/TRUNCATE formatters
vistart Sep 13, 2026
391712e
refactor(postgres): split dialect methods into feature mixins
vistart Sep 13, 2026
efbcd3d
feat(postgres): format_* signature compliance - ILIKE + Enum + Multir…
vistart Sep 14, 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
28 changes: 14 additions & 14 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@feature/parameter-inline"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -231,8 +231,8 @@ jobs:
fail-fast: false
matrix:
include:
- { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: false }
- { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: true }
- { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: false }
- { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: true }

services:
postgres:
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@feature/parameter-inline"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl"

- 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@feature/parameter-inline"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl"

- 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@feature/parameter-inline"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl"

- 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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "rhosocial-activerecord-postgres"
version = "1.0.0.dev16"
version = "1.0.0.dev17"
description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support."
readme = "README.md"
license = { file = "LICENSE" }
Expand Down 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
2 changes: 1 addition & 1 deletion src/rhosocial/activerecord/backend/impl/postgres/README.md
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
4 changes: 4 additions & 0 deletions src/rhosocial/activerecord/backend/impl/postgres/__init__.py
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
8 changes: 4 additions & 4 deletions src/rhosocial/activerecord/backend/impl/postgres/__main__.py
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
44 changes: 41 additions & 3 deletions src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py
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
Loading
Loading