From d6823550f6b0ce878af0f2b8ca0a8f60ad25b5de Mon Sep 17 00:00:00 2001 From: Seva D Date: Mon, 31 Aug 2026 04:21:16 +0000 Subject: [PATCH 1/5] Keep the caller's foreign key, timestamp and column values on construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `_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 `_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.` 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. --- docs/api-reference.md | 1 + docs/guides/models-and-fields.md | 4 + docs/guides/querying.md | 8 ++ docs/guides/relations.md | 13 +++ python/yara_orm/fields.py | 24 +++++ python/yara_orm/models.py | 61 ++++++++++-- tests/test_fields.py | 18 ++++ tests/test_model_extras.py | 154 ++++++++++++++++++++++++++++++- 8 files changed, 275 insertions(+), 8 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 14199a7..4faafa2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -73,6 +73,7 @@ Base class for models. See [Models & fields](guides/models-and-fields.md). | `update_from_dict` | `instance.update_from_dict(data)` | Set fields in place (no DB write). | | `fetch_related` | `await instance.fetch_related(*names)` | Populate relations on the instance. | | `pk` | `instance.pk` | Primary key value. | +| `__iter__` | `dict(instance)` | Iterate the instance as `(name, value)` column pairs. | The inner `Meta` class supports `table`, `table_description` / `description`, `abstract` (mark as a base model with no table; not inherited by subclasses), diff --git a/docs/guides/models-and-fields.md b/docs/guides/models-and-fields.md index db3d418..7f94e8c 100644 --- a/docs/guides/models-and-fields.md +++ b/docs/guides/models-and-fields.md @@ -234,6 +234,10 @@ default automatically; see [Primary keys](#primary-keys) below. to refresh the value on every `save()`. In the canonical `Author` model, `created_at` uses `auto_now_add=True`. + `auto_now_add` **fills** the column, it does not impose a value: a row + created with an explicit stamp (a backdated fixture, an import, a backfill) + keeps it. `auto_now` always stamps — that is the column's contract. + !!! note "ISO-8601 string input is coerced" `DateField`, `DatetimeField` and `TimeField` accept ISO-8601 **string** input (a trailing `Z` is accepted) and coerce it to a real `date` / `datetime` / diff --git a/docs/guides/querying.md b/docs/guides/querying.md index 48fa1fc..2623bf1 100644 --- a/docs/guides/querying.md +++ b/docs/guides/querying.md @@ -493,8 +493,16 @@ book.update_from_dict({"title": "New", "rating": 4}) # set fields, no DB write await book.save() await book.refresh_from_db() # reload column values from the row + +dict(book) # {"id": 1, "title": "New", ...} ``` +Instances iterate as `(name, value)` pairs, so `dict(instance)` works and +consumers that walk an object that way — pydantic's `from_attributes`, +serializers, factories — take a model directly. Columns come in declaration +order; a column the instance does not carry (`only()` / `defer()`) is skipped +rather than fetched, and private attributes are omitted. + ## Projections: `values()` and `values_list()` When you only need a few columns, project them directly. Both methods skip model construction, so they are faster for pure reads. diff --git a/docs/guides/relations.md b/docs/guides/relations.md index 04be622..f14bb66 100644 --- a/docs/guides/relations.md +++ b/docs/guides/relations.md @@ -113,6 +113,19 @@ instance, so `book.author` never serves a stale object. Assigning an **unsaved** instance (its pk is still `None`) raises `ValueError` — save the related row first. +When **both** forms arrive at construction — the relation object and its raw +column — the explicit id wins: + +```python +book = Book(author=other_author, author_id=ada.id) +book.author_id # ada.id — the id you passed +await book.author # Ada; the mismatched object is not cached +``` + +This is what a factory needs: a `SubFactory` declaration is evaluated even when +the caller also passes `author_id=`, and the relation resolving last would +silently replace the id that was asked for. + ### Reverse manager The `related_name` installs a manager on the target model. It is awaitable (to a diff --git a/python/yara_orm/fields.py b/python/yara_orm/fields.py index 0ebf6ce..f6494fe 100644 --- a/python/yara_orm/fields.py +++ b/python/yara_orm/fields.py @@ -1696,6 +1696,30 @@ def __init__( #: Cached pk field of the target model, used to coerce bound values. self._target_pk_field: Field | None = None + def to_python_value(self, value: Any) -> Any: + """Coerce an assigned foreign-key value to the target pk's Python type. + + Mirrors :meth:`to_db` on the assignment path. ``create(parent_id=str(pk))`` + bound correctly (``to_db`` coerces) but left the raw ``str`` on the + instance, so ``obj.parent_id == parent.id`` was False until the row was + re-fetched. Declaring this override also enrols the ``_id`` column + in ``MetaInfo.coerced_fields``, so ``__init__`` normalises it like every + other coerced column. + + Args: + value: The Python value assigned to the ``_id`` column. + + Returns: + The value coerced by the target model's pk field, or unchanged when + that model is not registered yet. + """ + if value is None: + return None + target_pk = self._resolve_target_pk_field() + if target_pk is None: + return value + return target_pk.to_python_value(value) + def to_db(self, value: Any) -> Any: """Coerce a foreign-key value to the target primary key's type. diff --git a/python/yara_orm/models.py b/python/yara_orm/models.py index 4722491..4d7ae78 100644 --- a/python/yara_orm/models.py +++ b/python/yara_orm/models.py @@ -35,7 +35,7 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Generator, Sequence + from collections.abc import Callable, Generator, Iterator, Sequence from typing_extensions import Self @@ -877,6 +877,9 @@ class Model(metaclass=ModelMeta): def __init__(self, **kwargs: Any) -> None: """Initialise field values from keyword arguments. + A relation may be given as an object (``parent=``) or as its raw + column (``parent_id=...``); when both arrive, the explicit id wins. + Args: **kwargs: Field values, relation objects/ids, or db-column aliases. Unset fields fall back to their declared defaults. @@ -896,7 +899,13 @@ def __init__(self, **kwargs: Any) -> None: rel_overrides = {} for rel_name, info in meta.relations.items(): if rel_name in kwargs: - rel_overrides[rel_name] = (info, kwargs.pop(rel_name)) + # An explicit ``_id`` alongside the relation wins: a + # factory's ``SubFactory`` declaration is evaluated even when the + # caller passes the id, and the relation resolving last would + # silently replace the id that was asked for. + id_field = meta.fields[info.source_attr] + explicit_id = info.source_attr in kwargs or id_field.db_column in kwargs + rel_overrides[rel_name] = (info, kwargs.pop(rel_name), explicit_id) for rel_name in meta.m2m: if rel_name in kwargs: raise TypeError( @@ -919,18 +928,24 @@ def __init__(self, **kwargs: Any) -> None: # rest (the majority) assign directly at C speed. d[name] = field.to_python_value(value) if name in coerced else value - for rel_name, (info, value) in rel_overrides.items(): + for rel_name, (info, value, explicit_id) in rel_overrides.items(): if value is None: - self.__dict__[info.source_attr] = None + if not explicit_id: + self.__dict__[info.source_attr] = None elif isinstance(value, Model): if value.pk is None: raise ValueError( f'Cannot assign "{value!r}" to {type(self).__name__}.{rel_name}: ' f"the instance isn't saved in the database yet; save it first" ) - self.__dict__[info.source_attr] = value.pk - self.__dict__.setdefault("_prefetch", {})[rel_name] = value - else: + if not explicit_id: + self.__dict__[info.source_attr] = value.pk + # Only cache the object as the prefetched relation when it is + # the row the id names; otherwise ``await obj.`` would hand + # back an instance that is not the one the column points at. + if self.__dict__[info.source_attr] == value.pk: + self.__dict__.setdefault("_prefetch", {})[rel_name] = value + elif not explicit_id: self.__dict__[info.source_attr] = value if kwargs: @@ -955,6 +970,28 @@ def __await__(self) -> Generator[Any, Any, Self]: yield from () return self + def __iter__(self) -> Iterator[tuple[str, Any]]: + """Yield ``(name, value)`` for the columns this instance carries. + + Makes ``dict(instance)`` work and lets consumers that walk an object as + pairs — pydantic's ``from_attributes`` conversion, serializers, factories + — take a model directly. Declared columns come first, in declaration + order; a column absent from the instance (``only()``/``defer()``) is + skipped rather than fetched, and any extra attributes kept under + ``Meta.extra_kwargs = "store"`` follow. Private attributes are omitted. + + Yields: + ``(field_name, value)`` pairs. + """ + d = self.__dict__ + declared = self._meta.fields + for name in declared: + if name in d: + yield name, d[name] + for name, value in list(d.items()): + if name not in declared and not name.startswith("_"): + yield name, value + def __class_getitem__(cls, _item: Any) -> type: """Make model classes subscriptable for annotations (no-op). @@ -1186,6 +1223,16 @@ def _apply_auto_now(self, only: set[str] | None = None) -> None: continue if only is not None and name not in only: continue + # ``auto_now_add`` fills the stamp, it does not impose one: a row + # created with an explicit value (backdated fixtures, imports, + # backfills) keeps it. ``auto_now`` always stamps — that is the + # column's contract — and so does a field declaring both. + if ( + field.auto_now_add + and not field.auto_now + and self.__dict__.get(name) is not None + ): + continue setattr(self, name, now) async def save( diff --git a/tests/test_fields.py b/tests/test_fields.py index 91e50d2..c16ea98 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -264,6 +264,24 @@ async def test_foreign_key_coerces_str_value_to_target_uuid(db): assert again.parent_id == p.id +@pytest.mark.asyncio +async def test_foreign_key_id_assignment_coerces_to_target_pk_type(db): + """ + GIVEN a child constructed with the FK column set from a string id + WHEN the in-memory attribute is read back before any re-fetch + THEN it already holds the target pk's Python type + + ``to_db`` coerced the value at bind time, but the instance kept the raw + ``str``, so ``child.parent_id == parent.id`` was False until reload. + """ + p = await CompatParent.create(name="root") + + c = CompatChild(parent_id=str(p.id), payload={"a": 1}) + + assert isinstance(c.parent_id, uuid.UUID) + assert c.parent_id == p.id + + @pytest.mark.asyncio async def test_jsonfield_encoder_and_decoder_hooks_apply(db): """ diff --git a/tests/test_model_extras.py b/tests/test_model_extras.py index 8435d59..e1a50d2 100644 --- a/tests/test_model_extras.py +++ b/tests/test_model_extras.py @@ -1,6 +1,8 @@ """Model-level query shortcuts, clone/describe, Meta.constraints, FK db_constraint, the Random function and the extra validators (reference parity).""" +import datetime + import pytest from yara_orm import ( @@ -11,6 +13,7 @@ UniqueConstraint, ValidationError, fields, + timezone, ) from yara_orm.dialects import SqliteDialect from yara_orm.validators import CommaSeparatedIntegerListValidator, NumericValidator @@ -47,7 +50,18 @@ class Meta: table = "mx_ref" -MODELS = [MxTag, MxConstrained, MxRef] +class MxStamped(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=50) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "mx_stamped" + extra_kwargs = "store" + + +MODELS = [MxTag, MxConstrained, MxRef, MxStamped] # -- Model-level query shortcuts ---------------------------------------------- @@ -299,3 +313,141 @@ def test_comma_separated_integer_list_validator(): CommaSeparatedIntegerListValidator()("1,x,3") with pytest.raises(ValidationError): CommaSeparatedIntegerListValidator()("1,,3") + + +# -- Construction semantics ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_explicit_relation_id_wins_over_relation_object(db): + """ + GIVEN a relation passed as an object alongside an explicit ``_id`` + WHEN the instance is constructed + THEN the explicit id is the one stored and persisted + + A factory's ``SubFactory`` declaration is evaluated even when the caller + also passes the raw id; resolving the relation last would silently replace + the id that was asked for. + """ + wanted = await MxTag.create(name="wanted") + other = await MxTag.create(name="other") + + ref = MxRef(tag=other, tag_id=wanted.id) + assert ref.tag_id == wanted.id + + await ref.save() + assert (await MxRef.get(id=ref.id)).tag_id == wanted.id + + +@pytest.mark.asyncio +async def test_explicit_relation_id_does_not_cache_a_mismatched_object(db): + """ + GIVEN a relation object whose pk differs from the explicit ``_id`` + WHEN the relation is awaited + THEN the row the id names is fetched, not the object that was passed + """ + wanted = await MxTag.create(name="wanted") + other = await MxTag.create(name="other") + + ref = await MxRef.create(tag=other, tag_id=wanted.id) + + assert (await ref.tag).id == wanted.id + + +@pytest.mark.asyncio +async def test_relation_object_still_sets_the_id_when_no_explicit_id(db): + """ + GIVEN only a relation object (the ordinary case) + WHEN the instance is constructed + THEN its id is taken from the object and the object is cached as prefetched + """ + tag = await MxTag.create(name="only") + + ref = await MxRef.create(tag=tag) + + assert ref.tag_id == tag.id + assert (await ref.tag) is tag + + +@pytest.mark.asyncio +async def test_auto_now_add_keeps_an_explicit_created_at(db): + """ + GIVEN a row created with an explicit ``auto_now_add`` value + WHEN it is inserted + THEN the supplied stamp is kept, not replaced with the current time + + ``auto_now_add`` fills the column when nothing was set; backdated fixtures, + imports and backfills supply their own. + """ + # Derived from the ORM's clock so the value matches the session's ``use_tz`` + # awareness on every backend. + backdated = timezone.now() - datetime.timedelta(days=365) + + row = await MxStamped.create(name="imported", created_at=backdated) + + assert row.created_at == backdated + again = await MxStamped.get(id=row.id) + # PostgreSQL hands back an aware UTC value even when ``use_tz`` is off, and + # both stamps are UTC, so the persisted one is compared in its naive form. + assert again.created_at.replace(tzinfo=None) == backdated.replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_auto_now_add_fills_an_unset_created_at(db): + """ + GIVEN a row created without a ``created_at`` + WHEN it is inserted + THEN the column is stamped with the current time + """ + before = timezone.now() + + row = await MxStamped.create(name="fresh") + + assert row.created_at >= before - datetime.timedelta(seconds=1) + + +@pytest.mark.asyncio +async def test_auto_now_always_stamps_even_when_supplied(db): + """ + GIVEN a row created with an explicit ``auto_now`` value + WHEN it is inserted + THEN the column is stamped anyway — ``auto_now`` owns its value + """ + backdated = timezone.now() - datetime.timedelta(days=365) + + row = await MxStamped.create(name="stamped", updated_at=backdated) + + assert row.updated_at > backdated + + +@pytest.mark.asyncio +async def test_model_instance_iterates_as_name_value_pairs(db): + """ + GIVEN a saved instance, including an attribute kept by ``extra_kwargs`` + WHEN it is iterated (``dict(instance)``) + THEN every column comes back as a ``(name, value)`` pair, extras included, + and private attributes are omitted + """ + row = await MxStamped.create(name="iterable", label="extra") + + as_dict = dict(row) + + assert as_dict["id"] == row.id + assert as_dict["name"] == "iterable" + assert as_dict["created_at"] == row.created_at + assert as_dict["label"] == "extra" + assert not [key for key in as_dict if key.startswith("_")] + + +@pytest.mark.asyncio +async def test_deferred_column_is_skipped_by_iteration(db): + """ + GIVEN an instance fetched with ``only()`` + WHEN it is iterated + THEN the columns it does not carry are skipped rather than fetched + """ + await MxStamped.create(name="partial") + + row = await MxStamped.all().only("id", "name").first() + + assert dict(row).keys() == {"id", "name"} From 13b1afb5828e52e3e7b170022ea6256e9334a706 Mon Sep 17 00:00:00 2001 From: Seva D Date: Mon, 31 Aug 2026 08:29:54 +0400 Subject: [PATCH 2/5] Keep the lint gate green under ty 0.0.75 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. --- python/yara_orm/connection.py | 4 ++-- python/yara_orm/models.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python/yara_orm/connection.py b/python/yara_orm/connection.py index 1e2af62..c5fb7e7 100644 --- a/python/yara_orm/connection.py +++ b/python/yara_orm/connection.py @@ -9,7 +9,7 @@ import asyncio import contextvars from collections.abc import Awaitable, Callable, Coroutine -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable from . import _engine, registry from . import timezone as _tz @@ -1681,7 +1681,7 @@ async def __aexit__( exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, - ) -> bool: + ) -> Literal[False]: """Commit/release on clean exit, roll back on error, and unpin. Args: diff --git a/python/yara_orm/models.py b/python/yara_orm/models.py index 4d7ae78..363fa04 100644 --- a/python/yara_orm/models.py +++ b/python/yara_orm/models.py @@ -2131,10 +2131,10 @@ def _natural_key(cls, values: dict[str, Any] | Model, key_fields: tuple[str, ... meta = cls._meta out = [] for name in key_fields: - if isinstance(values, dict): - value = values[name] # ty: ignore[invalid-argument-type] - else: + if isinstance(values, Model): value = getattr(values, name) + else: + value = values[name] field = meta.fields.get(name) out.append(field.to_db(value) if field is not None else value) return tuple(out) From eb1e1396ee48ecd42f9d18bff47a2aeeefb3f318 Mon Sep 17 00:00:00 2001 From: Seva D Date: Mon, 31 Aug 2026 08:44:54 +0400 Subject: [PATCH 3/5] Run the construction-semantics tests on Oracle too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_construction_semantics.py | 177 +++++++++++++++++++++++++++ tests/test_model_extras.py | 154 +---------------------- 2 files changed, 178 insertions(+), 153 deletions(-) create mode 100644 tests/test_construction_semantics.py diff --git a/tests/test_construction_semantics.py b/tests/test_construction_semantics.py new file mode 100644 index 0000000..723ef90 --- /dev/null +++ b/tests/test_construction_semantics.py @@ -0,0 +1,177 @@ +"""Construction semantics: explicit ``_id`` precedence over a relation +object, ``auto_now_add`` honouring a supplied stamp, and ``Model.__iter__``. + +These live apart from ``test_model_extras`` so they run on every backend: that +module's schema carries a raw ``CHECK (age >= 0)`` Oracle cannot create, which +keeps all of its ``db`` tests on the Oracle skip list.""" + +import datetime + +import pytest + +from yara_orm import Model, fields, timezone + + +class CsTag(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=50) + + class Meta: + table = "cs_tag" + + +class CsRef(Model): + id = fields.IntField(pk=True) + tag = fields.ForeignKeyField("CsTag", related_name="refs", db_constraint=False) + + class Meta: + table = "cs_ref" + + +class CsStamped(Model): + id = fields.IntField(pk=True) + name = fields.CharField(max_length=50) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "cs_stamped" + extra_kwargs = "store" + + +MODELS = [CsTag, CsRef, CsStamped] + + +@pytest.mark.asyncio +async def test_explicit_relation_id_wins_over_relation_object(db): + """ + GIVEN a relation passed as an object alongside an explicit ``_id`` + WHEN the instance is constructed + THEN the explicit id is the one stored and persisted + + A factory's ``SubFactory`` declaration is evaluated even when the caller + also passes the raw id; resolving the relation last would silently replace + the id that was asked for. + """ + wanted = await CsTag.create(name="wanted") + other = await CsTag.create(name="other") + + ref = CsRef(tag=other, tag_id=wanted.id) + assert ref.tag_id == wanted.id + + await ref.save() + assert (await CsRef.get(id=ref.id)).tag_id == wanted.id + + +@pytest.mark.asyncio +async def test_explicit_relation_id_does_not_cache_a_mismatched_object(db): + """ + GIVEN a relation object whose pk differs from the explicit ``_id`` + WHEN the relation is awaited + THEN the row the id names is fetched, not the object that was passed + """ + wanted = await CsTag.create(name="wanted") + other = await CsTag.create(name="other") + + ref = await CsRef.create(tag=other, tag_id=wanted.id) + + assert (await ref.tag).id == wanted.id + + +@pytest.mark.asyncio +async def test_relation_object_still_sets_the_id_when_no_explicit_id(db): + """ + GIVEN only a relation object (the ordinary case) + WHEN the instance is constructed + THEN its id is taken from the object and the object is cached as prefetched + """ + tag = await CsTag.create(name="only") + + ref = await CsRef.create(tag=tag) + + assert ref.tag_id == tag.id + assert (await ref.tag) is tag + + +@pytest.mark.asyncio +async def test_auto_now_add_keeps_an_explicit_created_at(db): + """ + GIVEN a row created with an explicit ``auto_now_add`` value + WHEN it is inserted + THEN the supplied stamp is kept, not replaced with the current time + + ``auto_now_add`` fills the column when nothing was set; backdated fixtures, + imports and backfills supply their own. + """ + # Derived from the ORM's clock so the value matches the session's ``use_tz`` + # awareness on every backend. + backdated = timezone.now() - datetime.timedelta(days=365) + + row = await CsStamped.create(name="imported", created_at=backdated) + + assert row.created_at == backdated + again = await CsStamped.get(id=row.id) + # PostgreSQL hands back an aware UTC value even when ``use_tz`` is off, and + # both stamps are UTC, so the persisted one is compared in its naive form. + assert again.created_at.replace(tzinfo=None) == backdated.replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_auto_now_add_fills_an_unset_created_at(db): + """ + GIVEN a row created without a ``created_at`` + WHEN it is inserted + THEN the column is stamped with the current time + """ + before = timezone.now() + + row = await CsStamped.create(name="fresh") + + assert row.created_at >= before - datetime.timedelta(seconds=1) + + +@pytest.mark.asyncio +async def test_auto_now_always_stamps_even_when_supplied(db): + """ + GIVEN a row created with an explicit ``auto_now`` value + WHEN it is inserted + THEN the column is stamped anyway — ``auto_now`` owns its value + """ + backdated = timezone.now() - datetime.timedelta(days=365) + + row = await CsStamped.create(name="stamped", updated_at=backdated) + + assert row.updated_at > backdated + + +@pytest.mark.asyncio +async def test_model_instance_iterates_as_name_value_pairs(db): + """ + GIVEN a saved instance, including an attribute kept by ``extra_kwargs`` + WHEN it is iterated (``dict(instance)``) + THEN every column comes back as a ``(name, value)`` pair, extras included, + and private attributes are omitted + """ + row = await CsStamped.create(name="iterable", label="extra") + + as_dict = dict(row) + + assert as_dict["id"] == row.id + assert as_dict["name"] == "iterable" + assert as_dict["created_at"] == row.created_at + assert as_dict["label"] == "extra" + assert not [key for key in as_dict if key.startswith("_")] + + +@pytest.mark.asyncio +async def test_deferred_column_is_skipped_by_iteration(db): + """ + GIVEN an instance fetched with ``only()`` + WHEN it is iterated + THEN the columns it does not carry are skipped rather than fetched + """ + await CsStamped.create(name="partial") + + row = await CsStamped.all().only("id", "name").first() + + assert dict(row).keys() == {"id", "name"} diff --git a/tests/test_model_extras.py b/tests/test_model_extras.py index e1a50d2..8435d59 100644 --- a/tests/test_model_extras.py +++ b/tests/test_model_extras.py @@ -1,8 +1,6 @@ """Model-level query shortcuts, clone/describe, Meta.constraints, FK db_constraint, the Random function and the extra validators (reference parity).""" -import datetime - import pytest from yara_orm import ( @@ -13,7 +11,6 @@ UniqueConstraint, ValidationError, fields, - timezone, ) from yara_orm.dialects import SqliteDialect from yara_orm.validators import CommaSeparatedIntegerListValidator, NumericValidator @@ -50,18 +47,7 @@ class Meta: table = "mx_ref" -class MxStamped(Model): - id = fields.IntField(pk=True) - name = fields.CharField(max_length=50) - created_at = fields.DatetimeField(auto_now_add=True) - updated_at = fields.DatetimeField(auto_now=True) - - class Meta: - table = "mx_stamped" - extra_kwargs = "store" - - -MODELS = [MxTag, MxConstrained, MxRef, MxStamped] +MODELS = [MxTag, MxConstrained, MxRef] # -- Model-level query shortcuts ---------------------------------------------- @@ -313,141 +299,3 @@ def test_comma_separated_integer_list_validator(): CommaSeparatedIntegerListValidator()("1,x,3") with pytest.raises(ValidationError): CommaSeparatedIntegerListValidator()("1,,3") - - -# -- Construction semantics ---------------------------------------------------- - - -@pytest.mark.asyncio -async def test_explicit_relation_id_wins_over_relation_object(db): - """ - GIVEN a relation passed as an object alongside an explicit ``_id`` - WHEN the instance is constructed - THEN the explicit id is the one stored and persisted - - A factory's ``SubFactory`` declaration is evaluated even when the caller - also passes the raw id; resolving the relation last would silently replace - the id that was asked for. - """ - wanted = await MxTag.create(name="wanted") - other = await MxTag.create(name="other") - - ref = MxRef(tag=other, tag_id=wanted.id) - assert ref.tag_id == wanted.id - - await ref.save() - assert (await MxRef.get(id=ref.id)).tag_id == wanted.id - - -@pytest.mark.asyncio -async def test_explicit_relation_id_does_not_cache_a_mismatched_object(db): - """ - GIVEN a relation object whose pk differs from the explicit ``_id`` - WHEN the relation is awaited - THEN the row the id names is fetched, not the object that was passed - """ - wanted = await MxTag.create(name="wanted") - other = await MxTag.create(name="other") - - ref = await MxRef.create(tag=other, tag_id=wanted.id) - - assert (await ref.tag).id == wanted.id - - -@pytest.mark.asyncio -async def test_relation_object_still_sets_the_id_when_no_explicit_id(db): - """ - GIVEN only a relation object (the ordinary case) - WHEN the instance is constructed - THEN its id is taken from the object and the object is cached as prefetched - """ - tag = await MxTag.create(name="only") - - ref = await MxRef.create(tag=tag) - - assert ref.tag_id == tag.id - assert (await ref.tag) is tag - - -@pytest.mark.asyncio -async def test_auto_now_add_keeps_an_explicit_created_at(db): - """ - GIVEN a row created with an explicit ``auto_now_add`` value - WHEN it is inserted - THEN the supplied stamp is kept, not replaced with the current time - - ``auto_now_add`` fills the column when nothing was set; backdated fixtures, - imports and backfills supply their own. - """ - # Derived from the ORM's clock so the value matches the session's ``use_tz`` - # awareness on every backend. - backdated = timezone.now() - datetime.timedelta(days=365) - - row = await MxStamped.create(name="imported", created_at=backdated) - - assert row.created_at == backdated - again = await MxStamped.get(id=row.id) - # PostgreSQL hands back an aware UTC value even when ``use_tz`` is off, and - # both stamps are UTC, so the persisted one is compared in its naive form. - assert again.created_at.replace(tzinfo=None) == backdated.replace(tzinfo=None) - - -@pytest.mark.asyncio -async def test_auto_now_add_fills_an_unset_created_at(db): - """ - GIVEN a row created without a ``created_at`` - WHEN it is inserted - THEN the column is stamped with the current time - """ - before = timezone.now() - - row = await MxStamped.create(name="fresh") - - assert row.created_at >= before - datetime.timedelta(seconds=1) - - -@pytest.mark.asyncio -async def test_auto_now_always_stamps_even_when_supplied(db): - """ - GIVEN a row created with an explicit ``auto_now`` value - WHEN it is inserted - THEN the column is stamped anyway — ``auto_now`` owns its value - """ - backdated = timezone.now() - datetime.timedelta(days=365) - - row = await MxStamped.create(name="stamped", updated_at=backdated) - - assert row.updated_at > backdated - - -@pytest.mark.asyncio -async def test_model_instance_iterates_as_name_value_pairs(db): - """ - GIVEN a saved instance, including an attribute kept by ``extra_kwargs`` - WHEN it is iterated (``dict(instance)``) - THEN every column comes back as a ``(name, value)`` pair, extras included, - and private attributes are omitted - """ - row = await MxStamped.create(name="iterable", label="extra") - - as_dict = dict(row) - - assert as_dict["id"] == row.id - assert as_dict["name"] == "iterable" - assert as_dict["created_at"] == row.created_at - assert as_dict["label"] == "extra" - assert not [key for key in as_dict if key.startswith("_")] - - -@pytest.mark.asyncio -async def test_deferred_column_is_skipped_by_iteration(db): - """ - GIVEN an instance fetched with ``only()`` - WHEN it is iterated - THEN the columns it does not carry are skipped rather than fetched - """ - await MxStamped.create(name="partial") - - row = await MxStamped.all().only("id", "name").first() - - assert dict(row).keys() == {"id", "name"} From 2d918ad7f8d6a835e8b8289a18bdc2d685caac1d Mon Sep 17 00:00:00 2001 From: Seva D Date: Mon, 31 Aug 2026 08:44:54 +0400 Subject: [PATCH 4/5] =?UTF-8?q?release=201.16.0=20=E2=80=94=20construction?= =?UTF-8?q?=20semantics:=20explicit=20FK=20ids=20win,=20auto=5Fnow=5Fadd?= =?UTF-8?q?=20honours=20a=20supplied=20stamp,=20Model.=5F=5Fiter=5F=5F,=20?= =?UTF-8?q?bulk=5Fupdate=20bind-param=20clamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- pyproject.toml | 2 +- python/yara_orm/__init__.py | 2 +- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7cb14..a15ce10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to **yara-orm** are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.16.0] - 2026-08-31 + +### Added + +- **`Model.__iter__`** — instances iterate as `(name, value)` pairs, so + `dict(instance)` works and consumers that walk an object that way + (pydantic's `from_attributes`, serializers, factories) take a model + directly. Declared columns come first, in declaration order; a column the + instance does not carry (`only()` / `defer()`) is skipped rather than + fetched, private attributes are omitted, and any extras kept under + `Meta.extra_kwargs = "store"` follow. Code that relied on `iter(instance)` + raising `TypeError` changes behaviour. + +### Changed + +- **An explicit `_id` wins over a relation object at construction.** + Relations used to be applied last and unconditionally, so + `Book(author=obj, author_id=x)` silently stored `obj.pk`. The explicit id + (by field name or db-column alias) is now kept, and an object whose pk + disagrees is not cached as the prefetched relation, so `await book.author` + cannot return a row the column does not point at. This is what factories + need: a `SubFactory` declaration is evaluated even when the caller also + passes `author_id=`. Passing only one of the two is unchanged. +- **`auto_now_add` fills the column instead of imposing a value.** A row + created with an explicit stamp (backdated fixtures, imports, backfills) + keeps it instead of being restamped at `now()`. `auto_now` still always + stamps, and so does a field declaring both. + +### Fixed + +- **Foreign-key columns are coerced on assignment.** `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` now mirrors `to_db`, and the + `_id` column joins `MetaInfo.coerced_fields` so `__init__` + normalises it like every other coerced column. Row hydration is untouched. +- **`bulk_update` batches respect the dialect's bind-parameter ceiling.** + Each row costs one `(pk, value)` pair per updated field in the `CASE` arms + plus a pk slot in the `WHERE ... IN` list, so a large `batch_size` could + exceed PostgreSQL's 65535 / SQL Server's 2100 limit and fail. Batches are + now clamped from `max_bind_params`, and SQLite declares its default + `SQLITE_MAX_VARIABLE_NUMBER` (32766) instead of inheriting 65535. + ## [1.15.0] - 2026-07-16 ### Added diff --git a/Cargo.toml b/Cargo.toml index 357f707..4325f9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orm-engine" -version = "1.15.0" +version = "1.16.0" edition = "2021" [lib] diff --git a/pyproject.toml b/pyproject.toml index 42a9ece..bd2aa7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] # Distribution name on PyPI is "yara-orm"; the import package is `yara_orm`. name = "yara-orm" -version = "1.15.0" +version = "1.16.0" description = "Fast async Python ORM with a Rust engine — Tortoise-style models, querysets, relations and migrations for PostgreSQL, MySQL, MariaDB and SQLite" readme = "README.md" requires-python = ">=3.9" diff --git a/python/yara_orm/__init__.py b/python/yara_orm/__init__.py index 0eefe7a..9f86743 100644 --- a/python/yara_orm/__init__.py +++ b/python/yara_orm/__init__.py @@ -64,7 +64,7 @@ class User(Model): except ImportError: # pragma: no cover _engine_version = "unbuilt" -__version__ = "1.15.0" +__version__ = "1.16.0" __all__ = [ "YaraOrm", From ad6ac84af10be55c3c4ffcbc0362e1baad6a43c3 Mon Sep 17 00:00:00 2001 From: Seva D Date: Mon, 31 Aug 2026 04:50:33 +0000 Subject: [PATCH 5/5] Cover the last construction branches under the 100% gate Three paths the new tests left untaken, each a real precedence case: - `=None` alongside an explicit `_id` keeps the id (the relation does not clear it). - A raw pk under the relation name alongside an explicit `_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. --- tests/test_construction_semantics.py | 29 ++++++++++++++++++++++++++++ tests/test_fields.py | 16 +++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/tests/test_construction_semantics.py b/tests/test_construction_semantics.py index 723ef90..25630af 100644 --- a/tests/test_construction_semantics.py +++ b/tests/test_construction_semantics.py @@ -78,6 +78,35 @@ async def test_explicit_relation_id_does_not_cache_a_mismatched_object(db): assert (await ref.tag).id == wanted.id +@pytest.mark.asyncio +async def test_explicit_relation_id_wins_over_a_none_relation(db): + """ + GIVEN ``=None`` alongside an explicit ``_id`` + WHEN the instance is constructed + THEN the id is kept rather than cleared + """ + tag = await CsTag.create(name="kept") + + ref = await CsRef.create(tag=None, tag_id=tag.id) + + assert ref.tag_id == tag.id + + +@pytest.mark.asyncio +async def test_explicit_relation_id_wins_over_a_raw_relation_value(db): + """ + GIVEN a raw pk passed under the relation name and an explicit ``_id`` + WHEN the instance is constructed + THEN the explicit id wins over the value given under the relation name + """ + wanted = await CsTag.create(name="wanted") + other = await CsTag.create(name="other") + + ref = await CsRef.create(tag=other.id, tag_id=wanted.id) + + assert ref.tag_id == wanted.id + + @pytest.mark.asyncio async def test_relation_object_still_sets_the_id_when_no_explicit_id(db): """ diff --git a/tests/test_fields.py b/tests/test_fields.py index c16ea98..9b78d13 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -282,6 +282,22 @@ async def test_foreign_key_id_assignment_coerces_to_target_pk_type(db): assert c.parent_id == p.id +def test_foreign_key_value_passes_through_before_the_target_is_registered(): + """ + GIVEN a foreign-key field whose target model is not registered + WHEN a value is assigned or bound + THEN it passes through unchanged instead of raising + + Both directions consult the target's pk field, which cannot be resolved + until the model is registered (during schema/relation setup). + """ + field = fields.ForeignKeyField("CompatNotRegistered") + + assert field.to_python_value("abc") == "abc" + assert field.to_db("abc") == "abc" + assert field.to_python_value(None) is None + + @pytest.mark.asyncio async def test_jsonfield_encoder_and_decoder_hooks_apply(db): """