Skip to content

test(typed-column): contract the rebuilt column layer and the protocol guarantees - #24

Merged
vistart merged 10 commits into
release/v1.0.0.dev17from
test/typed-column-contracts
Oct 11, 2026
Merged

vistart merged 10 commits into
release/v1.0.0.dev17from
test/typed-column-contracts

Conversation

@vistart

@vistart vistart commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Description

Rebuilds the shared contract layer so that the suite validates the column layer the way the core now defines it: which operations a column offers is decided by its class rather than by a universal base, and every operation is rendered through exactly one hook instead of being assembled from a function name. The contracts pin those guarantees directly, and the suite's own fixtures migrate to the same doctrine, so a backend that runs this suite exercises the new surface rather than the old one.

Six things arrive together, because they are one design seen from different heights.

The typed-column contracts and the protocol guarantees. The column-expression and protocol contracts are rewritten for the ColumnTypeSupport tables: an explicit declaration is backend-independent, inference is compared against each backend's own suggested_column_types() answer, and a backend refusal (None) skips instead of failing. The guarantees assert completeness of the common entries (column_helpers.py now carries the canonical COMMON_TYPES list and the selection mirror), class-or-None answers, constructibility, the absence of a value-family label, and the UseColumnType escape hatch past a refusal.

The sync to the new core accessors. The DDLSource contract and the derivation tests follow the core's rename of the DataType accessors (column_type → column_data_type, columns_type() → columns_data_type()). A new declaration-accessor contract pins that the UseColumnType marker is presented as written (identity and .column_class), that a field declaring nothing answers None, and that two declarations on one field fail with TypeError. The declaration accessors are pinned independent of each other: a field declaring only UseColumnType answers None on column_data_type and one declaring only UseSqlType answers None on column_type, while a field declaring both answers both with identity preserved on each side.

The DDL-source unknown-field → KeyError contract. The contract used to pin a split — the metadata-based accessors answered None/[] for a field the model does not have while the model_fields-based ones raised KeyError. The core's ruling unifies it: a field the model does not have is a mistake in the call, not an answer. The contract is updated accordingly — every field accessor refuses it with KeyError, and an explicitly named unknown field fails the batch accessor the same way, while None (or an empty collection) remains the answer for a real field that declares nothing.

Core-level Model.c operation gates. The operation surface is exercised through the field proxy, the entry point a model actually uses: every family's operations are reachable and produce expressions (string transforms and pattern, string-to-integer feeding integer arithmetic, numeric value plus transcendentals, JSON accessors, temporal operations plus interval arithmetic, boolean predicates, binary comparison and null tests), and operations a family does not offer refuse at construction — AttributeError for a missing attribute (as on an int, upper on a number result, created_at + created_at via the temporal operand gate) and TypeError where the interpreter itself refuses a family that never declared arithmetic (str + 1). Both are pinned, with the layer difference stated. Backends whose table answers None for an annotation skip the tests that need it (Firebird's dict is the recorded case), since a refusal is a legitimate answer. Core classes only: no backend wrappers, no rendered SQL text.

The DateTimeColumn → TimestampColumn follow. The column layer models values, and datetime is a vendor spelling, not a different value. The contracts follow the core's rename.

The bare-Column doctrine migration in the shared fixtures. The shared fixtures and the expression filler move off the doctrine that any operation can be called on any column: expressions are built from objects — a FunctionCall for a parameter that wraps one, a TableExpression for parameters annotated as one, ArrayType's required element_type supplied — matching what the query layer in core now does.

These contracts are the gate every backend runs against. They are not an internal asset: each of the nine backends' CI installs this suite and consumes exactly these contracts, so this repo's release branch is what the backends' CI must install. The nine backends currently install from this branch (their test.yml testsuite install lines point at @test/typed-column-contracts); once this merges, the follow-up is to repoint those install lines at release/v1.0.0.dev17, so that no release branch keeps depending on a feature branch that will be deleted.

Type of change

  • test: Adding missing tests and correcting existing tests for the new feature
  • feat
  • fix
  • docs
  • perf
  • chore
  • ci
  • build
  • revert

Breaking Change

  • Yes, for end-users (backward-incompatible API changes)
  • Yes, for backend developers (the contracts they are validated against change)

Impact on Backend Developers — yes. The suite is consumed by backend repositories, so its contracts are their API: the DDL-source accessor call sites move to column_data_type() / columns_data_type(), inference is validated against each backend's own ColumnTypeSupport tables rather than a fixed list, a backend refusal (None) is a legitimate answer that skips rather than fails, and an unknown field is now required to raise KeyError on every accessor. A backend built against the previous contracts will see these tests demand the new behaviour.

Related Repositories/Packages

This change cannot be used alone; the coordinated branch in each repo is feature/typed-columns (testsuite: test/typed-column-contracts).

Repository Branch State Required before this merge?
python-activerecord feature/typed-columns merged to release/v1.0.0.dev30 (#126) Already landed — the core accessors this branch syncs to are on the release branch
python-activerecord-{postgres,mysql,mariadb,oracle,sqlserver,firebird,clickhouse,snowflake,bigquery} feature/typed-columns ahead of each one's own release Yes, after this merge — each consumes these contracts in CI

Testing

  • I have added new tests to cover my changes.
  • I have updated existing tests to reflect my changes.
  • All existing tests pass — each backend's CI is the gate for these contracts; this repository has no self-test suite.

Test Plan: these contracts are executed by the backends' CI runs against this branch (core release/v1.0.0.dev30), which currently consume exactly this state of the suite. Per the project rules this repository has no test suite of its own (python-activerecord-testsuite is a shared test suite library with no self-tests), so local full-test verification does not apply here; the repo's own checkers (tools/check_sync_async_parity.py, check_structure.py, check_naming.py, check_markers.py) are the local gate.

Checklist

  • My code follows the project's code style guidelines.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation (the typed_column README follows the accessor renames and the guarantees).
  • My changes generate no new warnings.
  • Changelog fragment — not yet written. By this runbook's PR-first rule the fragment is created after the PR exists, named with the real PR number (Steps 14–15).
  • I have verified that my changes do not introduce SQL injection vulnerabilities (no SQL text is rendered in this repository).
  • N/A — performance regressions (contract tests only).

Additional Notes

The merge commit in this branch (3e5371a) is intentional, not an accidental merge. It merges two independent lines of work that were both needed on this branch at the same time: the Windows fcntl serial-run fix (5568e13, which is the fix itself) and the typed-column contracts line. The fcntl fix is worth stating on its own: the pool's teardown hook calls release_all after every test, and it imported the POSIX locking module at function entry, so a serial run on Windows failed each test with ModuleNotFoundError even though no slot lock had ever been taken — slot locks are only ever taken by _acquire_slot, reachable exclusively while pooling_active holds. The fix returns before the import when the worker holds no slot, and covers both the serial-run path and the POSIX-only locked path with regression tests. It is in this branch because CI pins this branch: whichever of the two is missing here is missing from every run that depends on it.

On the branch name: the testsuite's coordinated branches carry the test/* prefix (contract changes are test code), which is why this branch is test/typed-column-contracts rather than feature/*.

…module

The teardown hook calls release_all after every test, and it imported fcntl at function entry, so a serial run on Windows failed each test with ModuleNotFoundError even though no slot lock had ever been taken: slot locks are only ever taken by _acquire_slot, which is reachable exclusively while pooling_active holds. Return before the import when the worker holds no slot, and cover both the serial-run path and the POSIX-only locked path with regression tests.
DDLSource contract:
- rename the DataType accessor call sites (column_type ->
  column_data_type, columns_type() -> columns_data_type()) in the DDL
  contract and the derivation tests;
- add the declaration-accessor contract: the UseColumnType marker is
  presented as written (identity and .column_class), a field that declares
  none answers None, and two declarations on one field fail with TypeError.

typed_column contracts:
- rewrite the column-expression and protocol contracts for the
  ColumnTypeSupport tables: an explicit declaration is backend-independent,
  inference is compared against each backend's own suggested_column_types()
  answer, and a backend refusal (None) skips instead of failing;
- the guarantees now assert completeness of the common entries, class-or-
  None answers, constructibility, the absence of a value-family label, and
  the UseColumnType escape hatch past a refusal;
- add column_helpers.py with the canonical COMMON_TYPES list and the
  selection mirror; update the README.
…iour

- a field declaring only UseColumnType answers None on column_data_type,
  and a field declaring only UseSqlType answers None on column_type;
- a field declaring both answers both, identity preserved on each side
  (the new `stored` field on the declaration contract model);
- SyncContractModel.status is pinned as answering None on column_type;
- an unknown field raises KeyError on the model_fields-based accessor
  (column_type, beside field_python_type), while the metadata-based
  accessors answer None -- the split is stated in the test.
…egal)

Exercises the operation surface through the field proxy, the entry point a
model actually uses:

- each family's operations are reachable and produce expressions (string
  transforms and pattern, string-to-integer feeding integer arithmetic,
  numeric value + transcendental, JSON accessors, temporal operations +
  interval arithmetic, boolean predicates, binary comparison/null tests);
- operations a family does not offer refuse at construction: a missing
  attribute is AttributeError (like on an int, upper on a number result,
  created_at + created_at via the temporal operand gate), while a family
  that never declared arithmetic is refused by the interpreter (TypeError
  for str + 1) -- both pinned, with the layer difference stated;
- backends whose table answers None for an annotation skip the tests that
  need it (Firebird's dict is the recorded case), since a refusal is a
  legitimate answer.

Core classes only: no backend wrappers, no rendered SQL text.
The contract used to pin the split: the metadata-based accessors answered
None/[] for a field the model does not have while the model_fields-based
ones raised KeyError. The ruling unifies it -- a field the model does not
have is a mistake in the call, not an answer -- so every field accessor
now refuses it with KeyError, and an explicitly named unknown field fails
the batch accessor the same way. None (or an empty collection) remains
the answer for a real field that declares nothing.
Two independent problems, one branch that carries both:

- fcntl: release_all() imported the POSIX locking module even when the
  caller held no slot, which is what made a Windows serial run fail at
  teardown. Fixed in 5568e13.
- typed-column contracts: the shared expression filler has to hand a
  FunctionCall to a parameter that wraps one, and has to supply
  ArrayType's required element_type.

Core CI pins this branch, so whichever is missing here is missing from
every run that depends on it.
@vistart vistart added the enhancement New feature or request label Oct 11, 2026
@vistart vistart self-assigned this Oct 11, 2026
@sonarqubecloud

Copy link
Copy Markdown

@vistart
vistart merged commit e23020d into release/v1.0.0.dev17 Oct 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant