Skip to content

feat(model)!: one declaration surface per fact, declared by accessor - #140

Merged
vistart merged 2 commits into
release/v1.0.0.dev30from
feature/declaration-surface
Nov 12, 2026
Merged

vistart merged 2 commits into
release/v1.0.0.dev30from
feature/declaration-surface

Conversation

@vistart

@vistart vistart commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Description

A model used to declare the same fact twice: once as a class-body dunder and once as the classmethod that read it. __table_name__ held the name, table_name() returned it; __primary_key__ held the key, primary_key() returned it. Some consumers obeyed an overridden method, the rest read the dunder past it, so an override could work in one code path and be silently ignored in another — and nothing in the codebase said which of the two was authoritative.

This PR makes the accessor the declaration and removes the second source:

  • table_name(), schema_name(), primary_key(), pk_auto_generated(), deleted_at_field(), created_at_field(), updated_at_field(), version_field(), version_increment_by(), read_only(), logger_name(), no_track_fields() and uuid_version() are now declared by overriding them, and their defaults read no dunder at all.
  • table_name() has no default: a model that does not declare one fails with NotImplementedError at the first read, instead of carrying a None table name into a statement.
  • __column_types_cache__ — assigned but never read — is removed.
  • Framework code no longer reads __pk_auto_generated__ or the connection trio directly; pk_auto_generated(), backend_class(), connection_config() and logging_config() are the single read point.

The declaration surface itself is audited and documented in the same change: a new rules page (.claude/rules/declaration-surfaces.md) states the three surfaces a model may declare on, the accessor defaults, R1–R5, and the migration state of the legacy direct-write faces.

Three additions round out the surface:

  • UseIndex.columns — composite indexes on the field-level annotation surface, anchored on the field's own column (must be columns[0], the leftmost prefix), validated at class creation.
  • UseConstraint multi-column form — for branches that have a table-level counterpart (composite UNIQUE / FOREIGN KEY), surfaced through table_constraints().
  • UsePartitionKey — partition key as field annotations (extracted in field order) or a direct __partition_key__ tuple for introspected models; declaring both is a TypeError; a partition key the primary key does not contain is announced with a UserWarning; the query layer warns when a lookup constrains no partition column and the CLI says the same at filter time.

And two capabilities that the accessor surface made possible:

  • uuid_version() — "uuid4" by default, "uuid7" for time-ordered keys. The v7 generator is probed with getattr(uuid, "uuid7", None) rather than imported, because the stdlib gained it in 3.14 while this package supports 3.8 onward: selecting it on an older interpreter fails with a message naming the declaration and the requirement.
  • natural_key() — a model declares the fields that identify a row, and find_one_or_create accepts defaults alone, taking the declared key's values from them. An explicit keyword lookup still wins; a model that declares nothing behaves exactly as before.
  • reload() (sync and async) — the other half of the stale-record recipe: re-read the row into the same instance, reset change tracking, and re-point the optimistic lock at the committed version.

Adds # (no issue filed; the work is specified by .claude/plan/2026-11-11/declaration-surface-governance.md)

Type of change

  • feat: A new feature
  • docs: Documentation only changes (if related to the new feature)
  • test: Adding missing tests or correcting existing tests (for the new feature)
  • perf: A code change that improves performance (if part of the feature)
  • chore: Changes to the build process or auxiliary tools and libraries suchs as documentation generation (if related to the new feature)
  • ci: Changes to our CI configuration files and scripts (if related to the new feature)
  • build: Changes that affect the build system or external dependencies (if related to the new feature)
  • revert: Reverts a previous commit

Breaking Change

  • Yes, for end-users (backward-incompatible API changes)
  • Yes, for backend developers (internal architectural changes that may affect custom implementations)
  • No breaking changes

Impact on End-Users:

The ten declaration dunders no longer declare anything. A model must override the matching accessor instead:

Was Now
__table_name__ = "users" @classmethod def table_name(cls): return "users"
__schema_name__ = "crm" schema_name()
__primary_key__ = ("a", "b") primary_key() returning the tuple (None for a keyless model)
__pk_auto_generated__ = False pk_auto_generated() returning False
__deleted_at_field__ / __created_at_field__ / __updated_at_field__ deleted_at_field() / created_at_field() / updated_at_field()
__version_field__ / __version_increment_by__ version_field() / version_increment_by()
__read_only__ = True read_only() (inheriting ReadOnlyMixin already answers True)

A model with no table_name() override now fails loudly with NotImplementedError instead of running with a None table. There is no compatibility layer: this is a pre-1.0 breaking migration, made once and completely.

__partition_key__ remains a valid declaration (the entry point for introspected models); UsePartitionKey() is the other face, and declaring both is a TypeError.

Impact on Backend Developers (Custom Implementations):

  • Declaration dunders are no longer read, so a backend that read them (or that ships models declaring them) must switch to the accessors.
  • table_indexes() / table_constraints() now run the same unhandled-annotation gate every field-level accessor runs.
  • DDLSource gains partition_key_columns(); the locator accessors are declared by the field mixins rather than by DDLSourceMixin.
  • A backend handler must extend DDLAnnotationHandler.annotation_types rather than replace it: UsePartitionKey is now part of the core tuple.

Related Repositories/Packages

Coordinated migration, all on feature/declaration-surface, all verified against this core branch by their own CI:

Repository Change Status
python-activerecord-testsuite 534 declarations rewritten (fixtures + embedded model source) branch pushed, green via core/backends
python-activerecord-mysql 258 declarations + runtime-read/write forms CI green
python-activerecord-postgres 200 declarations + dynamic table/schema assignment CI green (13/13)
python-activerecord-clickhouse 253 declarations CI green
python-activerecord-oracle 24 declarations CI green
python-activerecord-sqlserver 21 declarations CI green
python-activerecord-mariadb 19 declarations CI green
python-activerecord-firebird 14 declarations CI green
python-activerecord-snowflake 16 declarations CI green
python-activerecord-bigquery 12 declarations + dataset-level schema assignment CI green

Every workflow on these branches installs core, testsuite and the backend under test from the matching feature/declaration-surface ref, so a run exercises one revision across the family.

Testing

  • I have added new tests to cover my changes.
  • I have updated existing tests to reflect my changes.
  • All existing tests pass.
  • This change has been tested on [SQLite (local suite), MySQL, MariaDB, PostgreSQL, SQL Server, Oracle, Firebird, ClickHouse, BigQuery, Snowflake via the backend repositories' CI].

Test Plan:

  • New files: test_ddl_composite_annotations.py (21 cases), test_ddl_partition_key.py (21), test_key_generation_and_natural_key.py (15), test_reload_stale_recipe.py (10, sync+async twins), test_soft_delete_partial_unique.py (5, round trip).
  • Guard tests extended: the marker registry now pins UsePartitionKey; table_indexes()/table_constraints() carry the unhandled-annotation gate; pk_auto_generated()/logger_name()/no_track_fields() are pinned against their defaults.
  • Local evidence before the migration: feature/ domain 12103 passed, 156 skipped; the only failures (4) are Windows temp-file-lock issues that fail identically on the base commit.
  • CI on this branch: full Python matrix 3.8–3.15t (including free-threaded) plus coverage and benchmarks — green; the backend repositories' full version/DB matrices — green.

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 (Docstrings, README.md, etc.).
    • 214 model examples across docs/ and .claude/ rewritten to the method form; user-visible errors now name the accessor.
  • My changes generate no new warnings.
  • I have added a Changelog fragment (changelog.d/<issue_number>.<type>.md).
    • Deliberately not yet: a fragment is named by the PR number, so it is written after this PR is opened (types added + removed; the declaration removal is the breaking half).
  • I have verified that my changes do not introduce SQL injection vulnerabilities.
  • I have checked for potential performance regressions.
    • Accessor reads are classmethod calls; the hot paths that used to read a dunder now call the accessor that already existed (pk_auto_generated(), column_name()), and no new per-row work was added.

Additional Notes

Evaluation evidence (.claude/skills/dev-merge-evaluation): purpose achieved (plan P0–P4 all ✅); topology main ⊆ release ⊆ feature holds; branch protection is a real ruleset with PR + status checks + linear history + signatures (note: an org admin bypass actor exists, so history should not be read as "everything was reviewed"); backend adaptation matrix complete (0 stale symbol references in all nine backends, testsuite and devtools); expression lineage gate clean (iron rule 0, placement 0, integrity 0; the two dangling TYPE_CHECKING imports are byte-identical to the release baseline).

Migration tooling is kept in scripts/ (d5_migrate_declarations.py, d5_migrate_docs.py, d5_migrate_embedded.py, p3_fix_reads_and_writes.py): the sweep is AST-located rather than regex-matched, preserves line endings and spacing, and is the reference for any repository still to migrate.

A model used to declare the same fact twice: once as a class-body dunder
and once as the classmethod that read it. Some consumers obeyed an
overridden method, the rest read the dunder past it, and nothing said
which was authoritative -- so an override could work in one code path
and be silently ignored in another.

This makes the accessor the declaration. table_name(), schema_name(),
primary_key(), pk_auto_generated(), the four locators
(deleted_at_field / created_at_field / updated_at_field / version_field),
version_increment_by(), read_only(), logger_name(), no_track_fields()
and uuid_version() are now declared by overriding them, and their
defaults read no dunder at all. table_name() has no default: a model
that does not declare one fails with NotImplementedError at the first
read instead of carrying a None table name into a statement.

The field-level surface gains composite declarations: UseIndex takes an
authoritative columns list (the annotated field must be its leftmost
column), UseConstraint gains the multi-column form for branches with a
table-level counterpart, and index names are uniqueness-checked across
both declaration surfaces at class creation. UsePartitionKey adds the
partition key as an annotation, extracted in field order, with
partition_key_columns() mirroring primary_key_columns(); the direct
__partition_key__ tuple remains the entry point for introspected models
and declaring both is a TypeError.

Three additions round out the declaration story: uuid_version() selects
time-ordered uuid7 keys (probed, not imported, since the stdlib gained
it in 3.14), natural_key() lets find_one_or_create take its key from
defaults, and reload() is the other half of the stale-record recipe --
re-read into the same instance and re-point the optimistic lock so a
retry is a retry rather than a second conflict. The soft-delete and
partial-unique-index recipe is documented and pinned by a round trip.

Breaking, and deliberately so: there is no compatibility layer. Every
model in this repository, the testsuite it drives, and nine backend
repositories were migrated by an AST-located sweep (kept under scripts/)
in the same change, so the family is consistent at this revision. The
rules page .claude/rules/declaration-surfaces.md states the policy the
code now implements.
@codecov

codecov Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.98456% with 197 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.49%. Comparing base (fc2098b) to head (82e157e).

Files with missing lines Patch % Lines
...record_test/feature/basic/fixtures/models_py310.py 66.66% 40 Missing ⚠️
...record_test/feature/basic/fixtures/models_py311.py 66.66% 40 Missing ⚠️
...activerecord_test/feature/basic/fixtures/models.py 80.55% 28 Missing ⚠️
...record_test/feature/basic/fixtures/models_py312.py 86.66% 16 Missing ⚠️
src/rhosocial/activerecord/base/fields.py 68.08% 9 Missing and 6 partials ⚠️
src/rhosocial/activerecord/base/base.py 78.78% 4 Missing and 10 partials ⚠️
...l/activerecord_test/feature/connection/conftest.py 41.66% 14 Missing ⚠️
src/rhosocial/activerecord/base/ddl.py 92.85% 5 Missing and 4 partials ⚠️
...iverecord_test/feature/relation/fixtures/models.py 88.88% 6 Missing ⚠️
src/rhosocial/activerecord/field/uuid.py 82.35% 2 Missing and 1 partial ⚠️
... and 7 more
Additional details and impacted files

Impacted file tree graph

@@                   Coverage Diff                    @@
##           release/v1.0.0.dev30     #140      +/-   ##
========================================================
- Coverage                 84.80%   84.49%   -0.32%     
========================================================
  Files                       720      720              
  Lines                     61335    62435    +1100     
  Branches                   6267     6344      +77     
========================================================
+ Hits                      52018    52755     +737     
- Misses                     7465     7809     +344     
- Partials                   1852     1871      +19     
Flag Coverage Δ
python-3.14 84.49% <80.98%> (-0.32%) ⬇️
unittests 84.49% <80.98%> (-0.32%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/rhosocial/activerecord/backend/errors.py 92.78% <ø> (ø)
src/rhosocial/activerecord/base/__init__.py 100.00% <ø> (ø)
src/rhosocial/activerecord/cli/backends.py 56.69% <100.00%> (ø)
src/rhosocial/activerecord/cli/errors.py 100.00% <ø> (ø)
src/rhosocial/activerecord/field/composite_pk.py 100.00% <100.00%> (ø)
src/rhosocial/activerecord/field/field_config.py 100.00% <ø> (ø)
src/rhosocial/activerecord/field/read_only.py 100.00% <100.00%> (ø)
src/rhosocial/activerecord/field/soft_delete.py 81.31% <100.00%> (+0.41%) ⬆️
src/rhosocial/activerecord/field/timestamp.py 96.49% <100.00%> (+0.12%) ⬆️
src/rhosocial/activerecord/field/version.py 85.43% <100.00%> (+1.06%) ⬆️
... and 25 more

... and 16 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sonarqubecloud

Copy link
Copy Markdown

@vistart vistart added enhancement New feature or request and removed feature labels Nov 12, 2026
@vistart vistart self-assigned this Nov 12, 2026
@vistart
vistart merged commit 969721b into release/v1.0.0.dev30 Nov 12, 2026
15 of 16 checks passed
@vistart
vistart deleted the feature/declaration-surface branch November 12, 2026 07:57
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