Skip to content

Keep the caller's foreign key, timestamp and column values on construction - #24

Merged
vsdudakov merged 5 commits into
mainfrom
fix/construction-and-timestamp-semantics
Aug 31, 2026
Merged

Keep the caller's foreign key, timestamp and column values on construction#24
vsdudakov merged 5 commits into
mainfrom
fix/construction-and-timestamp-semantics

Conversation

@vsdudakov

@vsdudakov vsdudakov commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Four places where a constructed instance quietly disagreed with the row it writes. All four surfaced while porting a large application onto yara-orm, and each one currently needs a shim in application code.

What changes

Before After
create(parent_id=str(pk)) bound correctly but left the raw str on the instance, so child.parent_id == parent.id was False until re-fetch ForeignKeyFieldInstance.to_python_value mirrors to_db, and the <name>_id column joins MetaInfo.coerced_fields so __init__ normalises it like every other coerced column
A relation object passed alongside an explicit <name>_id silently replaced that id (relations were applied last, unconditionally) The explicit id wins — by field name or db-column alias — and a relation object whose pk disagrees is not cached as the prefetched relation, so await obj.<rel> cannot return a row the column does not point at
auto_now_add overwrote a caller-supplied stamp, so backdated fixtures, imports and backfills landed at now() auto_now_add fills the column only when nothing was set. auto_now still always stamps, and so does a field declaring both
Model was not iterable, so dict(instance) and pair-walking consumers (pydantic from_attributes, serializers, factories) could not take a model directly Model.__iter__ yields (name, value) for the columns the instance carries, in declaration order — skipping columns absent under only()/defer() and private attributes, then any extras kept under Meta.extra_kwargs = "store"

The relation precedence matters most for factories: a SubFactory declaration is evaluated even when the caller also passes author_id=, so the old order made the explicitly requested id unreachable.

Compatibility

Behavioural changes, all in the direction of honouring what the caller passed:

  • A row created with an explicit auto_now_add value keeps it instead of being restamped.
  • <relation>=obj together with <relation>_id=x now stores x, not obj.pk. Passing only one of the two is unchanged.
  • Model instances are iterable, so code that relied on iter(instance) raising TypeError would change behaviour.

FK columns now go through to_python_value on the create path (a cached lookup of the target pk field); row hydration builds instances through __new__ and its decode plan, so reads are untouched.

Tests

Each behaviour and its counter-case, in tests/test_construction_semantics.py (its own module so it runs on Oracle too — test_model_extras's schema is on the Oracle skip list) and tests/test_fields.py: explicit id wins, mismatched object not cached, relation-only construction unchanged, explicit created_at preserved, unset created_at stamped, auto_now stamped even when supplied, iteration including extras, deferred columns skipped.

Full suite on SQLite + PostgreSQL: 1975 passed, 26 skipped. The test_concurrency.py / test_mt_concurrency.py pool tests are excluded from that run — this box is at its max_connections ceiling and they fail identically on released 1.15.0. ruff check, ruff format --check and ty check are clean.

Docs: relation precedence in the relations guide, the auto_now_add rule in models-and-fields, dict(instance) in the querying guide and the API reference.

…ction

Four places where an instance quietly disagreed with the row it writes, all
found while porting a large application onto yara-orm.

- ForeignKeyFieldInstance overrode to_db but not to_python_value, so
  `create(parent_id=str(pk))` bound correctly yet left the raw str on the
  instance: `child.parent_id == parent.id` was False until the row was
  re-fetched. The override mirrors to_db and enrols the `<name>_id` column in
  MetaInfo.coerced_fields, so __init__ normalises it like every other coerced
  column.
- Model.__init__ applied relation objects after the field loop and
  unconditionally, so a relation passed alongside an explicit `<name>_id`
  silently replaced the id that was asked for — exactly what a factory does,
  since a SubFactory declaration is evaluated even when the caller passes the
  id. The explicit id now wins (by name or db-column alias), and a relation
  object whose pk disagrees with it is no longer cached as the prefetched
  relation, so `await obj.<rel>` cannot hand back an instance the column does
  not point at.
- auto_now_add overwrote a caller-supplied stamp on insert, so backdated
  fixtures, imports and backfills landed at the current time. It now fills the
  column only when nothing was set; auto_now still always stamps, and so does a
  field declaring both.
- Model had no __iter__, so `dict(instance)` and consumers that walk an object
  as pairs (pydantic's from_attributes, serializers, factories) could not take a
  model directly. Instances now yield `(name, value)` for the columns they
  carry, in declaration order, skipping columns absent under only()/defer() and
  private attributes, followed by any extras kept under
  `Meta.extra_kwargs = "store"`.

Tests cover each behaviour and its counter-case; the relations, models-and-fields,
querying and API-reference docs describe the precedence rules.
CI installs the latest ty, which now flags two spots that 0.0.57 accepted:

- `TransactionWrapper.__aexit__` was annotated `-> bool`, which the newer
  checker reads as "may suppress the exception", so `atomic()`'s wrapper
  could fall through and implicitly return None. It always returns False,
  so annotate it `Literal[False]`.
- `_natural_key` narrowed on `isinstance(values, dict)` and needed a
  `ty: ignore` on the old checker that the new one reports as unused.
  Narrowing on `Model` first needs no suppression under either version.
The new tests sat in test_model_extras, whose module schema carries a raw
CHECK (age >= 0) that Oracle cannot create against its quoted "age"
column; every db test in that module is on the Oracle skip list for that
reason, and the new ones — not on the list — were the first to reach
generate_schemas there (ORA-00904: "AGE": invalid identifier).

Move them to tests/test_construction_semantics.py with their own models
so the behaviour is exercised on all six backends instead of skipped.
…w_add honours a supplied stamp, Model.__iter__, bulk_update bind-param clamp
Three paths the new tests left untaken, each a real precedence case:

- `<relation>=None` alongside an explicit `<name>_id` keeps the id (the
  relation does not clear it).
- A raw pk under the relation name alongside an explicit `<name>_id` loses to
  the explicit id.
- Both coercion directions on a foreign key pass a value through unchanged
  while the target model is still unregistered.
@vsdudakov
vsdudakov merged commit 504a7da into main Aug 31, 2026
4 checks passed
@vsdudakov
vsdudakov deleted the fix/construction-and-timestamp-semantics branch August 31, 2026 05:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant