Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>_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
`<name>_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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "orm-engine"
version = "1.15.0"
version = "1.16.0"
edition = "2021"

[lib]
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions docs/guides/models-and-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down
8 changes: 8 additions & 0 deletions docs/guides/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions docs/guides/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion python/yara_orm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions python/yara_orm/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions python/yara_orm/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<name>_id`` column
in ``MetaInfo.coerced_fields``, so ``__init__`` normalises it like every
other coerced column.

Args:
value: The Python value assigned to the ``<name>_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.

Expand Down
67 changes: 57 additions & 10 deletions python/yara_orm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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=<Node>``) 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.
Expand All @@ -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 ``<name>_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(
Expand All @@ -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.<rel>`` 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:
Expand All @@ -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).

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2084,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)
Expand Down
Loading