Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
42 changes: 23 additions & 19 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ permissions:
contents: read

jobs:
benchmark-python-314-postgres-18:
name: Run benchmarks with Python 3.14 / postgres 18
benchmark-python-315-postgres-18:
name: Run benchmarks with Python 3.15 / postgres 18
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -56,10 +56,10 @@ jobs:
- name: Check out the repository
uses: actions/checkout@v6

- name: Set up Python 3.14
- name: Set up Python 3.15
uses: actions/setup-python@v6
with:
python-version: "3.14"
python-version: "3.15"

- name: Install dependencies
run: |
Expand All @@ -69,7 +69,7 @@ jobs:
- name: Install activerecord and testsuite
run: |
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@feature/expression-schema-object-refactor"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -181,9 +181,9 @@ jobs:
if: always()
run: |
{
echo "## Python 3.14 / postgres 18 Benchmark Conclusion"
echo "## Python 3.15 / postgres 18 Benchmark Conclusion"
echo
echo "- Python: 3.14"
echo "- Python: 3.15"
echo "- postgres: 18"
echo "- Scope: testsuite generic benchmarks + ActiveRecord bulk benchmarks + PostgreSQL backend direct benchmarks"
echo "- Size: small"
Expand All @@ -202,9 +202,9 @@ jobs:
if: always()
run: |
{
echo "## Python 3.14 / postgres 18 FastAPI Benchmark Conclusion"
echo "## Python 3.15 / postgres 18 FastAPI Benchmark Conclusion"
echo
echo "- Python: 3.14"
echo "- Python: 3.15"
echo "- postgres: 18"
echo "- Scope: testsuite FastAPI database benchmarks"
echo "- Size: small"
Expand All @@ -219,20 +219,24 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

test-experimental-python-315:
# Temporarily disabled until Python 3.15 toolchain compatibility stabilizes.
if: ${{ false }}
name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} (experimental)
test-python-315:
name: Python ${{ matrix.python-version }}${{ matrix.freethreaded && 't' || '' }} / postgres ${{ matrix.postgres-version }}
runs-on: ubuntu-latest
permissions:
contents: read
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: false }
- { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: true }
# The free-threaded marker is in the job name even though there is
# currently no free-threaded leg, because two legs named alike is how
# a GIL pass and a free-threaded failure came to look like one job
# that both passed and failed.
#
# psycopg does not support free-threaded builds -- tracked as
# psycopg/psycopg#1095 -- so the GIL leg is the only one this backend
# has, on 3.15 as on every other interpreter.
- { python-version: '3.15', postgres-version: '18', freethreaded: false }

services:
postgres:
Expand Down Expand Up @@ -280,7 +284,7 @@ jobs:
- name: Install activerecord and testsuite
run: |
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@feature/expression-schema-object-refactor"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -392,7 +396,7 @@ jobs:
- name: Install activerecord and testsuite
run: |
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@feature/expression-schema-object-refactor"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts"

- name: Create postgres scenario config
run: |
Expand Down Expand Up @@ -490,7 +494,7 @@ jobs:
- name: Install activerecord and testsuite
run: |
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@feature/expression-schema-object-refactor"
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts"

- name: Create postgres scenario config
run: |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/62.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the PostgreSQL answer to the core column-type protocol and the four new pad/repeat/trim nodes. `PostgresColumnTypeMixin` and `POSTGRES_COLUMN_TYPES` carry the protocol's closed list of eighteen common Python types with every entry measured: `list`/`tuple`/`set`/`frozenset` answer `ArrayColumn` (native arrays, measured on 9.6/12/18 with the whole guarantee set green — `INT[]` DDL, a Python list bound directly and read back, `array_length`, 1-based `x[1]`, containment `x @> ARRAY[v]`, concatenation, `v = ANY(x)`, whole-column equality and `unnest`), `dict` answers `JSONColumn` while the DDL layer chooses `jsonb` through the `DataType` layer, `enum.Enum` answers `StringColumn`, and `float`/`decimal.Decimal` answer `NumericColumn` now that core removed `FloatColumn`/`DecimalColumn`/`RealColumn`/`DoubleColumn` and the numeric family is deliberately one class. `datetime.timedelta` is refused on purpose: PostgreSQL spells it `interval`, but no core column class names an interval's operations, and the previous `NumericColumn` answer offered `sqrt(interval '1 day')` for the engine to refuse at execution — a field declared `timedelta` now fails to resolve with a message naming the fix (`UseColumnType`). `PostgresDialect` composes the generic `TrimMixin`/`LpadMixin`/`RpadMixin`/`RepeatMixin` with no override, because PostgreSQL spells all four natively (`TRIM([BOTH|LEADING|TRAILING] [chars] FROM str)`, `LPAD`/`RPAD(str, len, padstr)`, `REPEAT(str, n)`). `PostgresUUIDMixin` and `PostgresUUIDAdapter` state the version-13-built-in-or-uuid-ossp answer and win over the core adapter's single fixed spelling, and `supports_uuid_ossp_extension` names the narrower question of whether uuid-ossp is installed.
1 change: 1 addition & 0 deletions changelog.d/62.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Change what the dialect answers and where the protocol bases sit. `format_cast_expression` renders its target through `expr.target_type.to_sql()` instead of interpolating it, so `col.cast(DecimalType(dialect))` produces `"col"::NUMERIC` rather than `"col"::DecimalType()`, and the `type_values` and expression examples that showed a string target now show a type object. `JSONSupport` is no longer listed in `PostgresDialect`'s protocol block alongside its own derived `PostgresJSONBEnhancedSupport` — a base listed ahead of its own subclass is not a consistent MRO, and the pair had put `supports_json_type` in the MRO four times (two abstract protocol declarations, the core mixin default and the one implementation); the protocol still reaches the MRO, so `isinstance(dialect, JSONSupport)` is unchanged. `PostgresTableMixin`'s data-type validation accepts underscores in a type name, which no valid PostgreSQL type spelling was previously allowed to contain. `PostgresSchemaDiffer` records the consequence of comparing snapshots against an introspected array column, which always reports `dimensions=1` because PostgreSQL normalises every array declaration to a single dimension on storage.
1 change: 1 addition & 0 deletions changelog.d/62.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix the probes that promised a server answer the server does not give, and two mixins listed in an order that made their own implementation dead code. `supports_data_type_postgres_jsonpath`, `postgres_xid8`, the six `postgres_*multirange` probes and `postgres_macaddr8` were unconditional `True` on types that arrived in PostgreSQL 12, 13, 14 and 10 respectively — a gate that cannot say no tells the dialect it can render the type on any server, and the disagreement arrives at execution, with the DDL built and sent, and the error naming a table rather than the version gap that caused it. `supports_jsonb_subscript` claimed 14.0+ for a capability PostgreSQL does not have at all (SQL subscripting covers arrays and composites; jsonb is read with `->`/`->>` or the SQL/JSON path language) and now answers `False` unconditionally, and `supports_json_arrow_operators` returned `True` unconditionally, advertising on 9.2 — where the operators do not parse — an arrow the core formatter would happily render; it is now gated at 9.3. The four version floors were measured against the scenario servers rather than read off the release notes, because the notes and the servers disagreed about jsonpath: the notes say 13, and 12 has it. `PostgresArrayEnhancedMixin` is now listed before the core `ArrayMixin` and `PostgresUUIDMixin` before the core `UUIDMixin`, where each answers `True` to a question the core answers `False` for; listed in the backend block, the core default was what every caller actually saw and the backend's implementation was unreachable. Each fix is pinned by a test in both directions, so a versioned type cannot again answer yes unconditionally while `text`/`integer`/`varchar` saying yes remains correct on purpose.
1 change: 1 addition & 0 deletions changelog.d/62.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Make Python 3.15 a required CI leg instead of an experimental one: `test-python-315` replaces the `if: ${{ false }}` / `continue-on-error` `test-experimental-python-315` job that ran a 3.15.0-rc.2 pair. The free-threaded 3.15 leg is dropped because psycopg does not support free-threaded builds (psycopg/psycopg#1095), but the free-threaded marker stays in the job *name*, since two legs named alike is how a GIL pass and a free-threaded failure came to look like one job that both passed and failed. The benchmark job moves to Python 3.15 / PostgreSQL 18. `requirements-dev.txt` pins pydantic 2.12.2 / pydantic-core 2.41.4 for Python 3.13 including the free-threaded 3.13t build, restated in this file because it is installed before the core package and an unpinned `pydantic` there resolves to a pydantic-core whose only cp313t distribution is an sdist that cannot build (PyO3 0.29 refuses the free-threaded build of CPython lower than 3.14). CI installs the core from its `release/v1.0.0.dev30` branch now that the column-layer rebuild has merged, and the testsuite from `test/typed-column-contracts`. The expression round-trip matrix now tells a missing formatter apart from a declined feature — core reports both through the same `UnsupportedFeatureError`, which let a class whose formatter this dialect never mixed in pass as "PostgreSQL lacks the feature" — so the method the dispatch names must be a row in the new `UNMODELLED_FORMATTERS` table with the reason (SQL:2003 PIVOT/UNPIVOT and the TableSource root, today), pinned in both directions. New coverage alongside it: column-type suggestions, cast integration, domain-expression and expression round-trips, extension type execution and operations, JSON path modes, UUID values, and the DDL-source accessor and DDL-object contracts.
1 change: 1 addition & 0 deletions changelog.d/62.removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**BREAKING**: Remove `PostgresCharacterVaryingType` from the `rhosocial.activerecord.backend.impl.postgres.expression` namespace and its `__all__`, remove `supports_uuid_generation` from `PostgresUuidOssSupport`/`PostgresUuidOssMixin` (renamed `supports_uuid_ossp_extension`, which asks the narrower question of whether the extension is installed; the merged answer now lives only in core's `UUIDSupport` and is folded by `PostgresUUIDMixin`, which answers True for a 13.0+ built-in with no extension at all), and remove `supports_infinity_numeric_infinity_jsonb` from `PostgresDataTypeMixin`, together with the two probes the core protocol's probe-family retirement asked this backend to stop declaring.
14 changes: 14 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,17 @@ rich

# Test timeout plugin for long-running database integration tests
pytest-timeout

# Python 3.13, including the free-threaded 3.13t build. The python_version
# marker distinguishes release lines, not GIL configuration, so these two lines
# pin both 3.13 builds. pydantic-core has no cp313t wheel after 2.41.4, and the
# newer sdists cannot be built there (PyO3 0.29 refuses the free-threaded build
# of CPython < 3.14); 2.12.2 is the last pydantic paired with 2.41.4.
#
# These mirror the same pins in rhosocial-activerecord's own dependencies, and
# they have to be restated here because this file is installed before the core
# package: resolving an unpinned `pydantic` here picks a pydantic-core whose only
# cp313t distribution is an sdist, and the build then fails on the free-threaded
# jobs before the core's pin is ever in scope.
pydantic[email]==2.12.2; python_version == '3.13'
pydantic-core==2.41.4; python_version == '3.13'
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def _register_postgres_adapters(self):
from ..adapters.monetary import PostgresMoneyAdapter
from ..adapters.network_address import PostgresMacaddrAdapter, PostgresMacaddr8Adapter
from ..adapters.object_identifier import PostgresOidAdapter, PostgresXidAdapter, PostgresTidAdapter
from ..adapters.uuid import PostgresUUIDAdapter
from ..adapters.pg_lsn import PostgresLsnAdapter
from ..adapters.text_search import PostgresTsVectorAdapter, PostgresTsQueryAdapter
from ..config import RangeAdapterMode
Expand All @@ -133,6 +134,11 @@ def _register_postgres_adapters(self):
PostgresOidAdapter(),
PostgresXidAdapter(),
PostgresTidAdapter(),
# Same {uuid.UUID: {str}} pair the core UUIDAdapter registers, so
# this needs allow_override (see the loop below). Unlike
# PostgresJsonPathAdapter it claims no str->str mapping, so it
# cannot shadow the default string handling.
PostgresUUIDAdapter(),
# PostgresJsonPathAdapter(), # Not registered: str->str conflict
# PostgresXMLAdapter is NOT registered by default
# due to str->str type pair conflict
Expand All @@ -141,7 +147,9 @@ def _register_postgres_adapters(self):
for adapter in pg_adapters:
for py_type, db_types in adapter.supported_types.items():
for db_type in db_types:
self.adapter_registry.register(adapter, py_type, db_type)
self.adapter_registry.register(
adapter, py_type, db_type, allow_override=True
)

# Register Range adapters based on configuration
range_mode = getattr(self.config, "range_adapter_mode", RangeAdapterMode.NATIVE)
Expand Down
48 changes: 44 additions & 4 deletions src/rhosocial/activerecord/backend/impl/postgres/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

WindowFunctionMixin,
JSONMixin,
UUIDMixin,

ArrayMixin,
ExplainMixin,
Expand All @@ -60,6 +61,15 @@
ILIKEMixin,
ConstraintMixin,
PartitionMixin,
# TRIM/LPAD/RPAD/REPEAT are nodes with default formatters, and PostgreSQL
# spells all four natively -- ``TRIM([BOTH|LEADING|TRAILING] [chars] FROM
# str)``, ``LPAD``/``RPAD(str, len, padstr)`` and ``REPEAT(str, n)`` -- so
# the shared defaults are the answer here and no override is needed.
LpadMixin,
RepeatMixin,
RpadMixin,
TrimMixin,

# New Mixins
PredicateMixin,
ExpressionMixin,
Expand Down Expand Up @@ -116,7 +126,6 @@
CTESupport,
FilterClauseSupport,
WindowFunctionSupport,
JSONSupport,
ReturningSupport,
AdvancedGroupingSupport,
ArraySupport,
Expand Down Expand Up @@ -160,6 +169,7 @@
PostgresDataTypeMixin,
PostgresLogicalReplicationMixin,
PostgresParallelQueryMixin,
PostgresColumnTypeMixin,
# Per-feature mixins
PostgresCTEMixin,
PostgresWindowMixin,
Expand Down Expand Up @@ -230,6 +240,7 @@
PostgresFullTextSearchMixin,
PostgresRangeTypeMixin,
PostgresJSONBEnhancedMixin,
PostgresUUIDMixin,
PostgresArrayEnhancedMixin,
PostgresTypeFormatSupportMixin,
# DDL/DML operation mixins (new)
Expand Down Expand Up @@ -418,6 +429,15 @@ class PostgresDialect(
PostgresCollationMixin,
PostgresOrderedSetAggMixin,
PostgresFeaturesMixin,
# TRIM/LPAD/RPAD/REPEAT are nodes with default formatters, and PostgreSQL
# spells all four natively: ``TRIM([BOTH|LEADING|TRAILING] [chars] FROM
# str)``, ``LPAD``/``RPAD(str, len, padstr)`` and ``REPEAT(str, n)``
# (functions-string.html; ``TRIM(BOTH 'xy' FROM 'xyxab')`` live-verified
# 2026-10-09 as the set form). No override is needed for any of them.
LpadMixin,
RepeatMixin,
RpadMixin,
TrimMixin,
SQLXMLMixin,
CollationMixin,
SetOperationMixin,
Expand All @@ -428,7 +448,15 @@ class PostgresDialect(
WindowFunctionMixin,
PostgresJSONBEnhancedMixin,
JSONMixin,

# PG's UUID answer (version 13+ built-in, or uuid-ossp) must win over
# the core UUIDMixin table, which has a single fixed spelling per operation.
PostgresUUIDMixin,
UUIDMixin,

# Before ArrayMixin: this answers True where the core answers False, and
# it was listed in the backend block further down, so the core default
# was what every caller actually saw.
PostgresArrayEnhancedMixin,
ArrayMixin,
ExplainMixin,
# Must precede GraphMixin/GraphTableMixin so that the explicit-override
Expand Down Expand Up @@ -475,6 +503,12 @@ class PostgresDialect(
PostgresDataTypeMixin,
PostgresLogicalReplicationMixin,
PostgresParallelQueryMixin,
# Column types. After the DataType mixin above because that is the
# sibling decision -- this one says which operations a value carries, the
# other says how it is spelled. Nothing else in the list can answer for a
# common Python type, so the MRO leaves the placement free; putting the two
# side by side is what makes the separation legible to the next reader.
PostgresColumnTypeMixin,
# Extension feature mixins
PostgresLtreeMixin,
PostgresIntarrayMixin,
Expand Down Expand Up @@ -516,7 +550,6 @@ class PostgresDialect(
MultirangeMixin,
PostgresFullTextSearchMixin,
PostgresRangeTypeMixin,
PostgresArrayEnhancedMixin,
PostgresTypeFormatSupportMixin,
# DDL/DML operation mixins (new)
PostgresExtendedStatisticsMixin,
Expand Down Expand Up @@ -544,7 +577,14 @@ class PostgresDialect(
CTESupport,
FilterClauseSupport,
WindowFunctionSupport,
JSONSupport,
# JSONSupport is not listed: PostgresJSONBEnhancedSupport derives from it
# below, and listing a base ahead of its own subclass is not a consistent
# MRO. This is the shape every other derived protocol uses here --
# PostgresTableSupport(TableSupport), PostgresIndexSupport(IndexSupport) --
# and JSONSupport still reaches the MRO, so isinstance(dialect, JSONSupport)
# is unchanged. Listing both, while each was standalone, also put
# supports_json_type in the MRO four times: two abstract protocol
# declarations, the core mixin default, and the one implementation.
ReturningSupport,
AdvancedGroupingSupport,
ArraySupport,
Expand Down
Loading
Loading