Skip to content

Model

Model is the base class every Ferro model inherits from. The lifecycle is: define a subclass with annotated fields (which registers its table schema), connect() to a database, then perform CRUD through classmethods (create, get, where, ...) and instance methods (save, delete, refresh). Because Model is a Pydantic model, instances validate on construction and serialize like any other Pydantic object.

Model

Bases: BaseModel

Provide the base class for all Ferro models

Inheriting from this class provisionally registers schema metadata in Python caches and exposes high-performance CRUD and query entrypoints. The Rust runtime is populated on first connect() via bulk install (#246).

Composite unique constraints: declare a typing.ClassVar named __ferro_composite_uniques__ as a tuple of tuples of column names (for example (("user_id", "org_id"),)) to enforce uniqueness on those columns together. This is separate from per-column uniqueness (Field(unique=True) on the field, Annotated[..., Field(unique=True)], or Annotated[..., FerroField(unique=True)]), each of which applies to a single column only. Default many-to-many join tables get a composite unique on their two foreign-key columns automatically.

Composite indexes: declare a typing.ClassVar named __ferro_composite_indexes__ as a tuple of tuples of column names (for example (("user_id", "created_at"),)) for non-unique multi-column indexes. Validation rules mirror __ferro_composite_uniques__: each inner tuple must contain at least two columns, columns must exist on the model, and order is preserved (matters for leftmost-prefix optimization). For single-column indexes use Field(index=True). Default many-to-many join tables get a non-unique reverse-direction composite index automatically; opt out with ManyToMany(reverse_index=False).

Table checks: declare a typing.ClassVar named __ferro_checks__ as a tuple of :class:~ferro.checks.Check objects — each a name suffix plus a ferro predicate lambda over this model's own columns, e.g. (Check("at_most_one_outflow", lambda transfer: (transfer.outflow_transaction == None) | (transfer.outflow_activity == None)),). The live constraint is ck_<table>_<suffix>, emitted inline in CREATE TABLE on both backends (ADR-0012, ADR-0014). For a single-column closed-domain CHECK use Field(db_check=True) instead.

Row security: declare a typing.ClassVar named __ferro_rls__ as a :class:~ferro.rowsecurity.RowSecurity container of :class:~ferro.rowsecurity.RowPolicy objects, e.g. RowSecurity(RowPolicy(column="tenant_id", setting="app.tenant_id")). A table created with it on gets ENABLE/FORCE ROW LEVEL SECURITY and its rls_<table>_<name> policies, so PostgreSQL — not application discipline — decides which rows a query can see. Row-level security is a Postgres-only schema object: on SQLite the model registers and the table is created, with a warning and no policies.

Examples:

>>> class User(Model):
...     id: int | None = None
...     name: str
Source code in src/ferro/models.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
class Model(BaseModel, metaclass=ModelMetaclass):
    """Provide the base class for all Ferro models

    Inheriting from this class provisionally registers schema metadata in Python
    caches and exposes high-performance CRUD and query entrypoints. The Rust
    runtime is populated on first ``connect()`` via bulk install (#246).

    **Composite unique constraints:** declare a ``typing.ClassVar`` named
    ``__ferro_composite_uniques__`` as a tuple of tuples of column names
    (for example ``(("user_id", "org_id"),)``) to enforce uniqueness on those
    columns together. This is separate from per-column uniqueness
    (``Field(unique=True)`` on the field, ``Annotated[..., Field(unique=True)]``,
    or ``Annotated[..., FerroField(unique=True)]``), each of which applies to a
    single column only. Default many-to-many join tables get a
    composite unique on their two foreign-key columns automatically.

    **Composite indexes:** declare a ``typing.ClassVar`` named
    ``__ferro_composite_indexes__`` as a tuple of tuples of column names
    (for example ``(("user_id", "created_at"),)``) for non-unique multi-column
    indexes. Validation rules mirror ``__ferro_composite_uniques__``: each
    inner tuple must contain at least two columns, columns must exist on the
    model, and order is preserved (matters for leftmost-prefix optimization).
    For single-column indexes use ``Field(index=True)``. Default many-to-many
    join tables get a non-unique reverse-direction composite index
    automatically; opt out with ``ManyToMany(reverse_index=False)``.

    **Table checks:** declare a ``typing.ClassVar`` named ``__ferro_checks__``
    as a tuple of :class:`~ferro.checks.Check` objects — each a name suffix
    plus a ferro predicate lambda over this model's own columns, e.g.
    ``(Check("at_most_one_outflow", lambda transfer: (transfer.outflow_transaction
    == None) | (transfer.outflow_activity == None)),)``. The live constraint is
    ``ck_<table>_<suffix>``, emitted inline in ``CREATE TABLE`` on both backends
    (ADR-0012, ADR-0014). For a single-column closed-domain CHECK use
    ``Field(db_check=True)`` instead.

    **Row security:** declare a ``typing.ClassVar`` named ``__ferro_rls__`` as a
    :class:`~ferro.rowsecurity.RowSecurity` container of
    :class:`~ferro.rowsecurity.RowPolicy` objects, e.g.
    ``RowSecurity(RowPolicy(column="tenant_id", setting="app.tenant_id"))``.
    A table created with it on gets ``ENABLE``/``FORCE ROW LEVEL SECURITY`` and
    its ``rls_<table>_<name>`` policies, so PostgreSQL — not application
    discipline — decides which rows a query can see. Row-level security is a
    Postgres-only schema object: on SQLite the model registers and the table is
    created, with a warning and no policies.

    Examples:
        >>> class User(Model):
        ...     id: int | None = None
        ...     name: str
    """

    __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ()
    __ferro_composite_indexes__: ClassVar[tuple[tuple[str, ...], ...]] = ()
    #: Table-level CHECK constraints (ADR-0012); an empty tuple is a no-op.
    __ferro_checks__: ClassVar[tuple["Check", ...]] = ()
    #: Row security declaration (PRD #406); ``None`` leaves the table unpoliced.
    __ferro_rls__: ClassVar["RowSecurity | None"] = None
    __ferro_columns__: ClassVar[dict[str, "ColumnSpec"]] = {}
    #: The single PK field name, derived once at the compile choke point
    #: alongside ``__ferro_columns__``; ``None`` for a PK-less model.
    __ferro_pk__: ClassVar[str | None] = None
    __ferro_relation_specs__: ClassVar[dict[str, "RelationSpec"]] = {}
    #: Reverse (BackRef) relation facts for existence tests (#314, ADR-0007),
    #: refreshed at the same compile choke point as ``__ferro_relation_specs__``.
    __ferro_reverse_specs__: ClassVar[dict[str, "ReverseSpec"]] = {}
    _enum_fields: ClassVar[dict[str, type[Enum]]] = {}

    @classmethod
    def _reregister_ferro(cls) -> None:
        """Re-persist this model's SchemaIR envelope (e.g. after envelope eviction)."""
        from .ir.compiler import compile_model_schema_ir

        compile_model_schema_ir(cls.__ferro_identity__, cls)

    model_config = ConfigDict(
        from_attributes=True,
        use_attribute_docstrings=True,
        arbitrary_types_allowed=True,
    )

    def __init__(self, **data: Any):
        """Initialize a model instance and normalize relationship inputs

        Args:
            **data: Field values used to construct the model.

        Examples:
            >>> user = User(name="Taylor")
            >>> isinstance(user, User)
            True
        """
        # 1. Handle relationship inputs (e.g. Product(category=my_cat))
        relations = getattr(self.__class__, "ferro_relations", {})
        for field_name, metadata in relations.items():
            if isinstance(metadata, ForeignKey) and field_name in data:
                val = data.pop(field_name)
                # If it's a Model instance, extract the ID
                if isinstance(val, Model):
                    # Read the *target* model's PK (FF-D D5) — the source
                    # model's PK name is irrelevant to the related instance.
                    pk_field = val.__class__.__ferro_pk__
                    if pk_field is None:
                        raise ValueError(
                            f"Cannot assign a {val.__class__.__name__} instance "
                            f"to relationship field {field_name!r}: "
                            f"{val.__class__.__name__} declares no primary-key "
                            "column, so there is no value to store in "
                            f"{field_name!r}_id. Pass the scalar value directly "
                            "or declare a primary_key=True column."
                        )
                    data[f"{field_name}_id"] = getattr(val, pk_field, None)
                else:
                    # It's already an ID or something else
                    data[f"{field_name}_id"] = val

        super().__init__(**data)

    @model_validator(mode="after")
    def _validate_required_foreign_keys(self) -> Self:
        """Keep Python model validation aligned with required FK nullability."""
        relations = getattr(self.__class__, "ferro_relations", {})
        for field_name, metadata in relations.items():
            if not isinstance(metadata, ForeignKey):
                continue
            if foreign_key_allows_none(metadata) is False:
                if getattr(self, f"{field_name}_id", None) is None:
                    raise ValueError(f"{field_name} is required")
        return self

    async def save(
        self,
        *,
        using: str | None = None,
        session: "Session | None" = None,
        on_conflict: Literal["update"] | None = None,
        only: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None,
        exclude: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None,
    ) -> None:
        """Persist the current model instance.

        A transient instance (constructed with ``Model(...)`` and never saved)
        is INSERTed — a duplicate primary key or unique value raises
        :class:`~ferro.exceptions.UniqueViolationError`. A persistent instance
        (fetched from the database, or previously saved) is UPDATEd by primary
        key. Pass ``on_conflict="update"`` for insert-or-update semantics
        regardless of persistence state (the primitive behind
        :meth:`upsert`).

        On a persisted UPDATE, ``only=`` is an explicit column allowlist
        and ``exclude=`` is the complementary denylist:
        ``await row.save(only={"messages", "updated_at"})`` writes those
        columns' current in-memory values (including ``None`` → NULL);
        ``await row.save(exclude={"turns"})`` writes every persisted
        column except the denylist. The primary key may appear in either
        set and is never SET. ``exclude=set()`` is a full write.
        Passing both ``only=`` and ``exclude=`` raises. The instance is
        not refreshed; omitted in-memory fields may diverge from the row.
        Mixins that assign ``updated_at`` then call ``super().save(**kwargs)``
        forward the write-set without expanding it. ``only=`` / ``exclude=``
        are rejected on INSERT and on ``on_conflict="update"``. Use
        :meth:`Query.update` for set-oriented or expression writes.

        Note that ``model_copy()`` copies persistence state: saving a copy of
        a persisted instance updates the same row. The UPDATE targets the
        instance's *current* primary-key value, so mutating the PK of a
        persisted instance before ``save()`` matches no row and raises. A row
        inserted inside a rolled-back transaction leaves the instance marked
        persisted; a later ``save()`` raises ``ModelDoesNotExist``.

        Args:
            using: Connection name override.
            session: Session scope for the operation.
            on_conflict: ``None`` (default) or ``"update"`` to upsert.
            only: Persisted-UPDATE column allowlist (``set`` / ``frozenset``
                / ``list`` / ``tuple`` of column names). ``None`` writes
                every column (unless ``exclude=`` is set).
            exclude: Persisted-UPDATE column denylist (same containers as
                ``only=``). ``exclude=set()`` is a full write.

        Raises:
            UniqueViolationError: A duplicate primary key or unique value on
                INSERT.
            ModelDoesNotExist: The row behind a persisted instance no longer
                exists (deleted underneath, or the PK was mutated).
            TypeError: ``only=`` or ``exclude=`` is a bare ``str`` or
                ``bytes``.
            ValueError: ``on_conflict`` is not ``None`` or ``"update"``, a
                persisted instance has no primary-key value, ``only=`` or
                ``exclude=`` is used on INSERT / upsert, both write-sets
                are passed, the write-set is empty after stripping the PK,
                or a name is unknown / a relation.

        Examples:
            >>> user = User(name="Taylor")
            >>> await user.save()
            >>> row.messages = msgs
            >>> row.updated_at = utcnow()
            >>> await row.save(only={"messages", "updated_at"})
            >>> await row.save(exclude={"turns"})
        """
        if on_conflict not in (None, "update"):
            raise ValueError(
                f'on_conflict must be None or "update", got {on_conflict!r}'
            )
        if only is not None:
            normalize_save_columns(only, param="only")
        if exclude is not None:
            normalize_save_columns(exclude, param="exclude")
        if only is not None and exclude is not None:
            raise ValueError(
                "save() accepts only one write-set: only= or exclude=, not both."
            )
        if only is not None or exclude is not None:
            which = "only" if only is not None else "exclude"
            if on_conflict is not None or not _is_persisted(self):
                raise ValueError(
                    f"save({which}=...) is only valid on a persisted UPDATE "
                    "(on_conflict must be None). "
                    "Use Query.update() for set-oriented or expression writes."
                )
        route, identity_using = await _instance_transaction_route(self, using, session)
        new_id = None
        if on_conflict == "update":
            new_id = await save_record(
                self.__class__.__ferro_identity__,
                save_bind_payload(self),
                route,
                mode="upsert",
            )
        elif _is_persisted(self):
            pk_field_name = self.__class__.__ferro_pk__
            pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
            if pk_val is None:
                raise ValueError(
                    f"Cannot UPDATE a persisted {self.__class__.__name__} "
                    "without a primary key value"
                )
            payload = save_bind_payload(self)
            if only is not None:
                payload = apply_save_only(self, payload, only)
            elif exclude is not None:
                payload = apply_save_exclude(self, payload, exclude)
            rows_affected = await update_record(
                self.__class__.__ferro_identity__,
                payload,
                route,
            )
            if rows_affected == 0:
                raise ModelDoesNotExist(self.__class__, pk_val)
        else:
            new_id = await save_record(
                self.__class__.__ferro_identity__,
                save_bind_payload(self),
                route,
                mode="insert",
            )

        pk_val = None
        pk_field_name = self.__class__.__ferro_pk__

        if pk_field_name is not None:
            spec = self.__class__.__ferro_columns__[pk_field_name]
            if (
                spec.autoincrement
                and getattr(self, pk_field_name) is None
                and new_id is not None
            ):
                setattr(self, pk_field_name, new_id)
            pk_val = getattr(self, pk_field_name)

        if pk_val is not None:
            register_instance(
                self.__class__.__ferro_identity__,
                str(pk_val),
                self,
                route,
            )
            _set_instance_origin(self, identity_using)
        _set_persisted(self, True)

    async def delete(
        self, *, using: str | None = None, session: "Session | None" = None
    ) -> None:
        """Delete the current model instance from storage

        Returns:
            None

        Examples:
            >>> user = await User.get_or_none(1)
            >>> if user:
            ...     await user.delete()
        """
        pk_field_name = self.__class__.__ferro_pk__
        pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
        route, _identity_using = await _instance_transaction_route(self, using, session)

        if pk_val is not None:
            name = self.__class__.__ferro_identity__
            query = Query(self.__class__, using=route.connection_name).where(
                _field_eq(pk_field_name, pk_val)
            )
            await query.delete()
            _core_evict_instance(name, str(pk_val), route)
            # The instance is transient again: a later save() re-INSERTs.
            _set_persisted(self, False)

    @classmethod
    async def all(
        cls, *, using: str | None = None, session: "Session | None" = None
    ) -> list[Self]:
        """Fetch all records for this model class

        Returns:
            A list of hydrated model instances.

        Examples:
            >>> users = await User.all()
            >>> isinstance(users, list)
            True
        """
        route = await _transaction_or_using(using, session)
        return await fetch_all(cls, route)

    @classmethod
    async def get(cls, pk: Any, *, session: "Session | None" = None) -> Self:
        """Fetch one record by primary key value.

        Args:
            pk: Primary key value to fetch a single record.

        Returns:
            The matching model instance.

        Raises:
            ModelDoesNotExist: When no row exists for this primary key. Use
                :meth:`get_or_none` if you need optional lookup without raising.

        Examples:
            >>> user = await User.get(1)
            >>> isinstance(user, User)
            True
        """
        instance = await cls.get_or_none(pk, session=session)
        if instance is None:
            raise ModelDoesNotExist(cls, pk)
        return instance

    @classmethod
    async def get_or_none(
        cls, pk: Any, *, session: "Session | None" = None
    ) -> Self | None:
        """Fetch one record by primary key, or return None if no row exists.

        Args:
            pk: Primary key value to fetch a single record.

        Returns:
            The matching model instance, or None when no record exists.
        """
        pk_field_name = cls.__ferro_pk__
        if pk_field_name is None:
            raise RuntimeError(f"Model {cls.__name__} does not define a primary key")

        return await cls.where(_field_eq(pk_field_name, pk), session=session).first()

    async def refresh(
        self, *, using: str | None = None, session: "Session | None" = None
    ) -> None:
        """Reload this instance from storage using its primary key

        Returns:
            None

        Raises:
            RuntimeError: If no primary key is available or the record no longer exists.

        Examples:
            >>> user = await User.get(1)
            >>> await user.refresh()
        """
        pk_field_name = self.__class__.__ferro_pk__
        pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None

        if pk_val is None:
            raise RuntimeError("Cannot refresh a model without a primary key")

        name = self.__class__.__ferro_identity__
        route, identity_using = await _instance_transaction_route(self, using, session)

        _core_evict_instance(name, str(pk_val), route)
        query = Query(self.__class__, using=route.connection_name).where(
            _field_eq(pk_field_name, pk_val)
        )
        fresh_instance = await query.first()

        if fresh_instance is None:
            raise RuntimeError(f"Instance not found in database: {name}({pk_val})")

        self.__dict__.update(fresh_instance.__dict__)
        register_instance(name, str(pk_val), self, route)
        _set_instance_origin(self, identity_using)
        _set_persisted(self, True)

    @classmethod
    def where(
        cls, predicate: "Predicate[Self]", *, session: "Session | None" = None
    ) -> Query[Self]:
        """Start a fluent query with an initial condition.

        ``predicate`` is a lambda of shape
        ``Callable[[QueryProxy[Self]], QueryNode]``, e.g.
        ``User.where(lambda user: user.age >= 18)``. The lambda receives a
        :class:`QueryProxy` whose attributes build comparisons as
        :class:`QueryNode` instances, so predicates type-check cleanly.
        Name the parameter after the model in lowercase singular (``user`` for
        ``User``, ``post`` for ``Post``). Column names are validated at build
        time against the model's declared fields (plus shadow ``{fk}_id``
        columns).

        Args:
            predicate: A callable that takes a :class:`QueryProxy` and
                returns a :class:`QueryNode`.

        Returns:
            A query object scoped to this model class.

        Examples:
            >>> q1 = User.where(lambda user: user.archived == False)  # noqa: E712
            >>> q2 = User.where(lambda user: user.id == 1)
            >>> isinstance(q1, Query) and isinstance(q2, Query)
            True
        """
        return Query(cls, session=session).where(predicate)

    @overload
    @classmethod
    def select(cls, *, session: "Session | None" = None) -> Query[Self]: ...

    @overload
    @classmethod
    def select(
        cls, selector: "RowSelector[Self]", *, session: "Session | None" = None
    ) -> "ProjectedQuery[Self]": ...

    @overload
    @classmethod
    def select(
        cls, *columns: str, session: "Session | None" = None
    ) -> "ProjectedQuery[Self]": ...

    @classmethod
    def select(
        cls,
        *selectors: "RowSelector[Self] | str",
        session: "Session | None" = None,
    ) -> "Query[Self] | ProjectedQuery[Self]":
        """Start a fluent query, optionally projected to a column subset.

        Bare ``select()`` starts a full query of complete model instances
        (unchanged). With a lambda selector —
        ``select(lambda t: (t.id, t.amount))``, or the single-field form
        ``select(lambda t: t.amount)`` — the query is a projection: its
        results are :class:`~ferro.query.Row` records in the list-like
        :class:`~ferro.query.Rows` container, never model instances
        (ADR-0007). Column-name strings (``select("id", "amount")``) follow
        ``order_by``'s string contract: root columns only, never mixed with
        a lambda. Both forms validate at build time with did-you-mean.

        Returns:
            A query object scoped to this model class; projected when a
            selector is given.

        Examples:
            >>> query = User.select().limit(5)
            >>> isinstance(query, Query)
            True
            >>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()  # doctest: +SKIP
        """
        return Query(cls, session=session).select(*selectors)

    @classmethod
    def using(cls, name: str) -> "ModelConnection[Self]":
        """Bind ORM operations for this model to a named connection."""
        return ModelConnection(cls, name)

    @classmethod
    async def create(cls, *, session: "Session | None" = None, **fields) -> Self:
        """Create and persist a new model instance

        ``create()`` is a plain INSERT: it never updates an existing row.

        Args:
            **fields: Field values to construct the model.

        Returns:
            The newly created and persisted model instance.

        Raises:
            UniqueViolationError: A row with the same primary key or unique
                value already exists — use :meth:`upsert` for
                insert-or-update semantics.

        Examples:
            >>> user = await User.create(name="Taylor")
            >>> isinstance(user, User)
            True
        """
        instance = cls(**fields)
        await instance.save(session=session)
        return instance

    @classmethod
    async def upsert(cls, *, session: "Session | None" = None, **fields) -> Self:
        """Insert the row, or update the existing row on primary-key conflict.

        Equivalent to ``cls(**fields).save(on_conflict="update")``. With an
        autoincrement primary key left unset there is no conflict target, so
        this degrades to a plain INSERT.

        Args:
            **fields: Field values to construct the model.

        Returns:
            The persisted model instance.

        Examples:
            >>> user = await User.upsert(id=1, name="Taylor")
            >>> isinstance(user, User)
            True
        """
        instance = cls(**fields)
        await instance.save(session=session, on_conflict="update")
        return instance

    @classmethod
    async def bulk_create(
        cls,
        instances: list[Self],
        *,
        using: str | None = None,
        session: "Session | None" = None,
    ) -> int:
        """Persist multiple instances in a single bulk operation

        Batch size is unbounded: batches larger than the backend's
        bind-parameter limit allows in one statement are split internally.
        The call is atomic either way — inside an ambient ``transaction()``
        that transaction is the atomicity boundary; a bare call is
        all-or-nothing (a failure on any row inserts none).

        Args:
            instances: Model instances to persist.

        Returns:
            The number of records inserted.

        Examples:
            >>> rows = await User.bulk_create([User(name="A"), User(name="B")])
            >>> isinstance(rows, int)
            True
        """
        if not instances:
            return 0
        data = [save_bind_payload(i) for i in instances]
        route = await _transaction_or_using(using, session)
        return await save_bulk_records(cls.__ferro_identity__, data, route)

    @classmethod
    async def get_or_create(
        cls,
        defaults: dict[str, Any] | None = None,
        *,
        session: "Session | None" = None,
        **fields,
    ) -> tuple[Self, bool]:
        """Fetch a record by filters or create one when missing

        Args:
            defaults: Values applied only when creating a new record.
            **fields: Exact-match filters used for lookup.

        Returns:
            A tuple of ``(instance, created)`` where ``created`` is True for new records.

        Examples:
            >>> user, created = await User.get_or_create(email="a@b.com")
            >>> isinstance(created, bool)
            True
        """
        query = Query(cls, session=session)
        for key, val in fields.items():
            query = query.where(_field_eq(key, val))

        instance = await query.first()
        if instance:
            return instance, False

        params = {**fields, **(defaults or {})}
        return await cls.create(session=session, **params), True

    @classmethod
    async def update_or_create(
        cls,
        defaults: dict[str, Any] | None = None,
        *,
        session: "Session | None" = None,
        **fields,
    ) -> tuple[Self, bool]:
        """Update a matched record or create one when missing

        Args:
            defaults: Values applied on update or create paths.
            **fields: Exact-match filters used for lookup.

        Returns:
            A tuple of ``(instance, created)`` where ``created`` is True for new records.
        """
        query = Query(cls, session=session)
        for key, val in fields.items():
            query = query.where(_field_eq(key, val))

        instance = await query.first()
        if instance:
            for key, val in (defaults or {}).items():
                setattr(instance, key, val)
            await instance.save(session=session)
            return instance, False

        params = {**fields, **(defaults or {})}
        return await cls.create(session=session, **params), True

Attributes

__ferro_composite_uniques__ = () class-attribute

__ferro_composite_indexes__ = () class-attribute

__ferro_checks__ = () class-attribute

__ferro_rls__ = None class-attribute

__ferro_columns__ = {} class-attribute

__ferro_pk__ = None class-attribute

__ferro_relation_specs__ = {} class-attribute

__ferro_reverse_specs__ = {} class-attribute

model_config = ConfigDict(from_attributes=True, use_attribute_docstrings=True, arbitrary_types_allowed=True) class-attribute instance-attribute

Functions

__init__(**data)

Initialize a model instance and normalize relationship inputs

Parameters:

Name Type Description Default
**data Any

Field values used to construct the model.

{}

Examples:

>>> user = User(name="Taylor")
>>> isinstance(user, User)
True
Source code in src/ferro/models.py
def __init__(self, **data: Any):
    """Initialize a model instance and normalize relationship inputs

    Args:
        **data: Field values used to construct the model.

    Examples:
        >>> user = User(name="Taylor")
        >>> isinstance(user, User)
        True
    """
    # 1. Handle relationship inputs (e.g. Product(category=my_cat))
    relations = getattr(self.__class__, "ferro_relations", {})
    for field_name, metadata in relations.items():
        if isinstance(metadata, ForeignKey) and field_name in data:
            val = data.pop(field_name)
            # If it's a Model instance, extract the ID
            if isinstance(val, Model):
                # Read the *target* model's PK (FF-D D5) — the source
                # model's PK name is irrelevant to the related instance.
                pk_field = val.__class__.__ferro_pk__
                if pk_field is None:
                    raise ValueError(
                        f"Cannot assign a {val.__class__.__name__} instance "
                        f"to relationship field {field_name!r}: "
                        f"{val.__class__.__name__} declares no primary-key "
                        "column, so there is no value to store in "
                        f"{field_name!r}_id. Pass the scalar value directly "
                        "or declare a primary_key=True column."
                    )
                data[f"{field_name}_id"] = getattr(val, pk_field, None)
            else:
                # It's already an ID or something else
                data[f"{field_name}_id"] = val

    super().__init__(**data)

save(*, using=None, session=None, on_conflict=None, only=None, exclude=None) async

Persist the current model instance.

A transient instance (constructed with Model(...) and never saved) is INSERTed — a duplicate primary key or unique value raises :class:~ferro.exceptions.UniqueViolationError. A persistent instance (fetched from the database, or previously saved) is UPDATEd by primary key. Pass on_conflict="update" for insert-or-update semantics regardless of persistence state (the primitive behind :meth:upsert).

On a persisted UPDATE, only= is an explicit column allowlist and exclude= is the complementary denylist: await row.save(only={"messages", "updated_at"}) writes those columns' current in-memory values (including None → NULL); await row.save(exclude={"turns"}) writes every persisted column except the denylist. The primary key may appear in either set and is never SET. exclude=set() is a full write. Passing both only= and exclude= raises. The instance is not refreshed; omitted in-memory fields may diverge from the row. Mixins that assign updated_at then call super().save(**kwargs) forward the write-set without expanding it. only= / exclude= are rejected on INSERT and on on_conflict="update". Use :meth:Query.update for set-oriented or expression writes.

Note that model_copy() copies persistence state: saving a copy of a persisted instance updates the same row. The UPDATE targets the instance's current primary-key value, so mutating the PK of a persisted instance before save() matches no row and raises. A row inserted inside a rolled-back transaction leaves the instance marked persisted; a later save() raises ModelDoesNotExist.

Parameters:

Name Type Description Default
using str | None

Connection name override.

None
session Session | None

Session scope for the operation.

None
on_conflict Literal['update'] | None

None (default) or "update" to upsert.

None
only set[str] | frozenset[str] | list[str] | tuple[str, ...] | None

Persisted-UPDATE column allowlist (set / frozenset / list / tuple of column names). None writes every column (unless exclude= is set).

None
exclude set[str] | frozenset[str] | list[str] | tuple[str, ...] | None

Persisted-UPDATE column denylist (same containers as only=). exclude=set() is a full write.

None

Raises:

Type Description
UniqueViolationError

A duplicate primary key or unique value on INSERT.

ModelDoesNotExist

The row behind a persisted instance no longer exists (deleted underneath, or the PK was mutated).

TypeError

only= or exclude= is a bare str or bytes.

ValueError

on_conflict is not None or "update", a persisted instance has no primary-key value, only= or exclude= is used on INSERT / upsert, both write-sets are passed, the write-set is empty after stripping the PK, or a name is unknown / a relation.

Examples:

>>> user = User(name="Taylor")
>>> await user.save()
>>> row.messages = msgs
>>> row.updated_at = utcnow()
>>> await row.save(only={"messages", "updated_at"})
>>> await row.save(exclude={"turns"})
Source code in src/ferro/models.py
async def save(
    self,
    *,
    using: str | None = None,
    session: "Session | None" = None,
    on_conflict: Literal["update"] | None = None,
    only: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None,
    exclude: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None,
) -> None:
    """Persist the current model instance.

    A transient instance (constructed with ``Model(...)`` and never saved)
    is INSERTed — a duplicate primary key or unique value raises
    :class:`~ferro.exceptions.UniqueViolationError`. A persistent instance
    (fetched from the database, or previously saved) is UPDATEd by primary
    key. Pass ``on_conflict="update"`` for insert-or-update semantics
    regardless of persistence state (the primitive behind
    :meth:`upsert`).

    On a persisted UPDATE, ``only=`` is an explicit column allowlist
    and ``exclude=`` is the complementary denylist:
    ``await row.save(only={"messages", "updated_at"})`` writes those
    columns' current in-memory values (including ``None`` → NULL);
    ``await row.save(exclude={"turns"})`` writes every persisted
    column except the denylist. The primary key may appear in either
    set and is never SET. ``exclude=set()`` is a full write.
    Passing both ``only=`` and ``exclude=`` raises. The instance is
    not refreshed; omitted in-memory fields may diverge from the row.
    Mixins that assign ``updated_at`` then call ``super().save(**kwargs)``
    forward the write-set without expanding it. ``only=`` / ``exclude=``
    are rejected on INSERT and on ``on_conflict="update"``. Use
    :meth:`Query.update` for set-oriented or expression writes.

    Note that ``model_copy()`` copies persistence state: saving a copy of
    a persisted instance updates the same row. The UPDATE targets the
    instance's *current* primary-key value, so mutating the PK of a
    persisted instance before ``save()`` matches no row and raises. A row
    inserted inside a rolled-back transaction leaves the instance marked
    persisted; a later ``save()`` raises ``ModelDoesNotExist``.

    Args:
        using: Connection name override.
        session: Session scope for the operation.
        on_conflict: ``None`` (default) or ``"update"`` to upsert.
        only: Persisted-UPDATE column allowlist (``set`` / ``frozenset``
            / ``list`` / ``tuple`` of column names). ``None`` writes
            every column (unless ``exclude=`` is set).
        exclude: Persisted-UPDATE column denylist (same containers as
            ``only=``). ``exclude=set()`` is a full write.

    Raises:
        UniqueViolationError: A duplicate primary key or unique value on
            INSERT.
        ModelDoesNotExist: The row behind a persisted instance no longer
            exists (deleted underneath, or the PK was mutated).
        TypeError: ``only=`` or ``exclude=`` is a bare ``str`` or
            ``bytes``.
        ValueError: ``on_conflict`` is not ``None`` or ``"update"``, a
            persisted instance has no primary-key value, ``only=`` or
            ``exclude=`` is used on INSERT / upsert, both write-sets
            are passed, the write-set is empty after stripping the PK,
            or a name is unknown / a relation.

    Examples:
        >>> user = User(name="Taylor")
        >>> await user.save()
        >>> row.messages = msgs
        >>> row.updated_at = utcnow()
        >>> await row.save(only={"messages", "updated_at"})
        >>> await row.save(exclude={"turns"})
    """
    if on_conflict not in (None, "update"):
        raise ValueError(
            f'on_conflict must be None or "update", got {on_conflict!r}'
        )
    if only is not None:
        normalize_save_columns(only, param="only")
    if exclude is not None:
        normalize_save_columns(exclude, param="exclude")
    if only is not None and exclude is not None:
        raise ValueError(
            "save() accepts only one write-set: only= or exclude=, not both."
        )
    if only is not None or exclude is not None:
        which = "only" if only is not None else "exclude"
        if on_conflict is not None or not _is_persisted(self):
            raise ValueError(
                f"save({which}=...) is only valid on a persisted UPDATE "
                "(on_conflict must be None). "
                "Use Query.update() for set-oriented or expression writes."
            )
    route, identity_using = await _instance_transaction_route(self, using, session)
    new_id = None
    if on_conflict == "update":
        new_id = await save_record(
            self.__class__.__ferro_identity__,
            save_bind_payload(self),
            route,
            mode="upsert",
        )
    elif _is_persisted(self):
        pk_field_name = self.__class__.__ferro_pk__
        pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
        if pk_val is None:
            raise ValueError(
                f"Cannot UPDATE a persisted {self.__class__.__name__} "
                "without a primary key value"
            )
        payload = save_bind_payload(self)
        if only is not None:
            payload = apply_save_only(self, payload, only)
        elif exclude is not None:
            payload = apply_save_exclude(self, payload, exclude)
        rows_affected = await update_record(
            self.__class__.__ferro_identity__,
            payload,
            route,
        )
        if rows_affected == 0:
            raise ModelDoesNotExist(self.__class__, pk_val)
    else:
        new_id = await save_record(
            self.__class__.__ferro_identity__,
            save_bind_payload(self),
            route,
            mode="insert",
        )

    pk_val = None
    pk_field_name = self.__class__.__ferro_pk__

    if pk_field_name is not None:
        spec = self.__class__.__ferro_columns__[pk_field_name]
        if (
            spec.autoincrement
            and getattr(self, pk_field_name) is None
            and new_id is not None
        ):
            setattr(self, pk_field_name, new_id)
        pk_val = getattr(self, pk_field_name)

    if pk_val is not None:
        register_instance(
            self.__class__.__ferro_identity__,
            str(pk_val),
            self,
            route,
        )
        _set_instance_origin(self, identity_using)
    _set_persisted(self, True)

delete(*, using=None, session=None) async

Delete the current model instance from storage

Returns:

Type Description
None

None

Examples:

>>> user = await User.get_or_none(1)
>>> if user:
...     await user.delete()
Source code in src/ferro/models.py
async def delete(
    self, *, using: str | None = None, session: "Session | None" = None
) -> None:
    """Delete the current model instance from storage

    Returns:
        None

    Examples:
        >>> user = await User.get_or_none(1)
        >>> if user:
        ...     await user.delete()
    """
    pk_field_name = self.__class__.__ferro_pk__
    pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None
    route, _identity_using = await _instance_transaction_route(self, using, session)

    if pk_val is not None:
        name = self.__class__.__ferro_identity__
        query = Query(self.__class__, using=route.connection_name).where(
            _field_eq(pk_field_name, pk_val)
        )
        await query.delete()
        _core_evict_instance(name, str(pk_val), route)
        # The instance is transient again: a later save() re-INSERTs.
        _set_persisted(self, False)

all(*, using=None, session=None) async classmethod

Fetch all records for this model class

Returns:

Type Description
list[Self]

A list of hydrated model instances.

Examples:

>>> users = await User.all()
>>> isinstance(users, list)
True
Source code in src/ferro/models.py
@classmethod
async def all(
    cls, *, using: str | None = None, session: "Session | None" = None
) -> list[Self]:
    """Fetch all records for this model class

    Returns:
        A list of hydrated model instances.

    Examples:
        >>> users = await User.all()
        >>> isinstance(users, list)
        True
    """
    route = await _transaction_or_using(using, session)
    return await fetch_all(cls, route)

get(pk, *, session=None) async classmethod

Fetch one record by primary key value.

Parameters:

Name Type Description Default
pk Any

Primary key value to fetch a single record.

required

Returns:

Type Description
Self

The matching model instance.

Raises:

Type Description
ModelDoesNotExist

When no row exists for this primary key. Use :meth:get_or_none if you need optional lookup without raising.

Examples:

>>> user = await User.get(1)
>>> isinstance(user, User)
True
Source code in src/ferro/models.py
@classmethod
async def get(cls, pk: Any, *, session: "Session | None" = None) -> Self:
    """Fetch one record by primary key value.

    Args:
        pk: Primary key value to fetch a single record.

    Returns:
        The matching model instance.

    Raises:
        ModelDoesNotExist: When no row exists for this primary key. Use
            :meth:`get_or_none` if you need optional lookup without raising.

    Examples:
        >>> user = await User.get(1)
        >>> isinstance(user, User)
        True
    """
    instance = await cls.get_or_none(pk, session=session)
    if instance is None:
        raise ModelDoesNotExist(cls, pk)
    return instance

get_or_none(pk, *, session=None) async classmethod

Fetch one record by primary key, or return None if no row exists.

Parameters:

Name Type Description Default
pk Any

Primary key value to fetch a single record.

required

Returns:

Type Description
Self | None

The matching model instance, or None when no record exists.

Source code in src/ferro/models.py
@classmethod
async def get_or_none(
    cls, pk: Any, *, session: "Session | None" = None
) -> Self | None:
    """Fetch one record by primary key, or return None if no row exists.

    Args:
        pk: Primary key value to fetch a single record.

    Returns:
        The matching model instance, or None when no record exists.
    """
    pk_field_name = cls.__ferro_pk__
    if pk_field_name is None:
        raise RuntimeError(f"Model {cls.__name__} does not define a primary key")

    return await cls.where(_field_eq(pk_field_name, pk), session=session).first()

refresh(*, using=None, session=None) async

Reload this instance from storage using its primary key

Returns:

Type Description
None

None

Raises:

Type Description
RuntimeError

If no primary key is available or the record no longer exists.

Examples:

>>> user = await User.get(1)
>>> await user.refresh()
Source code in src/ferro/models.py
async def refresh(
    self, *, using: str | None = None, session: "Session | None" = None
) -> None:
    """Reload this instance from storage using its primary key

    Returns:
        None

    Raises:
        RuntimeError: If no primary key is available or the record no longer exists.

    Examples:
        >>> user = await User.get(1)
        >>> await user.refresh()
    """
    pk_field_name = self.__class__.__ferro_pk__
    pk_val = getattr(self, pk_field_name) if pk_field_name is not None else None

    if pk_val is None:
        raise RuntimeError("Cannot refresh a model without a primary key")

    name = self.__class__.__ferro_identity__
    route, identity_using = await _instance_transaction_route(self, using, session)

    _core_evict_instance(name, str(pk_val), route)
    query = Query(self.__class__, using=route.connection_name).where(
        _field_eq(pk_field_name, pk_val)
    )
    fresh_instance = await query.first()

    if fresh_instance is None:
        raise RuntimeError(f"Instance not found in database: {name}({pk_val})")

    self.__dict__.update(fresh_instance.__dict__)
    register_instance(name, str(pk_val), self, route)
    _set_instance_origin(self, identity_using)
    _set_persisted(self, True)

where(predicate, *, session=None) classmethod

Start a fluent query with an initial condition.

predicate is a lambda of shape Callable[[QueryProxy[Self]], QueryNode], e.g. User.where(lambda user: user.age >= 18). The lambda receives a :class:QueryProxy whose attributes build comparisons as :class:QueryNode instances, so predicates type-check cleanly. Name the parameter after the model in lowercase singular (user for User, post for Post). Column names are validated at build time against the model's declared fields (plus shadow {fk}_id columns).

Parameters:

Name Type Description Default
predicate Predicate[Self]

A callable that takes a :class:QueryProxy and returns a :class:QueryNode.

required

Returns:

Type Description
Query[Self]

A query object scoped to this model class.

Examples:

>>> q1 = User.where(lambda user: user.archived == False)  # noqa: E712
>>> q2 = User.where(lambda user: user.id == 1)
>>> isinstance(q1, Query) and isinstance(q2, Query)
True
Source code in src/ferro/models.py
@classmethod
def where(
    cls, predicate: "Predicate[Self]", *, session: "Session | None" = None
) -> Query[Self]:
    """Start a fluent query with an initial condition.

    ``predicate`` is a lambda of shape
    ``Callable[[QueryProxy[Self]], QueryNode]``, e.g.
    ``User.where(lambda user: user.age >= 18)``. The lambda receives a
    :class:`QueryProxy` whose attributes build comparisons as
    :class:`QueryNode` instances, so predicates type-check cleanly.
    Name the parameter after the model in lowercase singular (``user`` for
    ``User``, ``post`` for ``Post``). Column names are validated at build
    time against the model's declared fields (plus shadow ``{fk}_id``
    columns).

    Args:
        predicate: A callable that takes a :class:`QueryProxy` and
            returns a :class:`QueryNode`.

    Returns:
        A query object scoped to this model class.

    Examples:
        >>> q1 = User.where(lambda user: user.archived == False)  # noqa: E712
        >>> q2 = User.where(lambda user: user.id == 1)
        >>> isinstance(q1, Query) and isinstance(q2, Query)
        True
    """
    return Query(cls, session=session).where(predicate)

select(*selectors, session=None) classmethod

select(*, session: Session | None = None) -> Query[Self]
select(selector: RowSelector[Self], *, session: Session | None = None) -> ProjectedQuery[Self]
select(*columns: str, session: Session | None = None) -> ProjectedQuery[Self]

Start a fluent query, optionally projected to a column subset.

Bare select() starts a full query of complete model instances (unchanged). With a lambda selector — select(lambda t: (t.id, t.amount)), or the single-field form select(lambda t: t.amount) — the query is a projection: its results are :class:~ferro.query.Row records in the list-like :class:~ferro.query.Rows container, never model instances (ADR-0007). Column-name strings (select("id", "amount")) follow order_by's string contract: root columns only, never mixed with a lambda. Both forms validate at build time with did-you-mean.

Returns:

Type Description
Query[Self] | ProjectedQuery[Self]

A query object scoped to this model class; projected when a

Query[Self] | ProjectedQuery[Self]

selector is given.

Examples:

>>> query = User.select().limit(5)
>>> isinstance(query, Query)
True
>>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()
Source code in src/ferro/models.py
@classmethod
def select(
    cls,
    *selectors: "RowSelector[Self] | str",
    session: "Session | None" = None,
) -> "Query[Self] | ProjectedQuery[Self]":
    """Start a fluent query, optionally projected to a column subset.

    Bare ``select()`` starts a full query of complete model instances
    (unchanged). With a lambda selector —
    ``select(lambda t: (t.id, t.amount))``, or the single-field form
    ``select(lambda t: t.amount)`` — the query is a projection: its
    results are :class:`~ferro.query.Row` records in the list-like
    :class:`~ferro.query.Rows` container, never model instances
    (ADR-0007). Column-name strings (``select("id", "amount")``) follow
    ``order_by``'s string contract: root columns only, never mixed with
    a lambda. Both forms validate at build time with did-you-mean.

    Returns:
        A query object scoped to this model class; projected when a
        selector is given.

    Examples:
        >>> query = User.select().limit(5)
        >>> isinstance(query, Query)
        True
        >>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()  # doctest: +SKIP
    """
    return Query(cls, session=session).select(*selectors)

using(name) classmethod

Bind ORM operations for this model to a named connection.

Source code in src/ferro/models.py
@classmethod
def using(cls, name: str) -> "ModelConnection[Self]":
    """Bind ORM operations for this model to a named connection."""
    return ModelConnection(cls, name)

create(*, session=None, **fields) async classmethod

Create and persist a new model instance

create() is a plain INSERT: it never updates an existing row.

Parameters:

Name Type Description Default
**fields

Field values to construct the model.

{}

Returns:

Type Description
Self

The newly created and persisted model instance.

Raises:

Type Description
UniqueViolationError

A row with the same primary key or unique value already exists — use :meth:upsert for insert-or-update semantics.

Examples:

>>> user = await User.create(name="Taylor")
>>> isinstance(user, User)
True
Source code in src/ferro/models.py
@classmethod
async def create(cls, *, session: "Session | None" = None, **fields) -> Self:
    """Create and persist a new model instance

    ``create()`` is a plain INSERT: it never updates an existing row.

    Args:
        **fields: Field values to construct the model.

    Returns:
        The newly created and persisted model instance.

    Raises:
        UniqueViolationError: A row with the same primary key or unique
            value already exists — use :meth:`upsert` for
            insert-or-update semantics.

    Examples:
        >>> user = await User.create(name="Taylor")
        >>> isinstance(user, User)
        True
    """
    instance = cls(**fields)
    await instance.save(session=session)
    return instance

upsert(*, session=None, **fields) async classmethod

Insert the row, or update the existing row on primary-key conflict.

Equivalent to cls(**fields).save(on_conflict="update"). With an autoincrement primary key left unset there is no conflict target, so this degrades to a plain INSERT.

Parameters:

Name Type Description Default
**fields

Field values to construct the model.

{}

Returns:

Type Description
Self

The persisted model instance.

Examples:

>>> user = await User.upsert(id=1, name="Taylor")
>>> isinstance(user, User)
True
Source code in src/ferro/models.py
@classmethod
async def upsert(cls, *, session: "Session | None" = None, **fields) -> Self:
    """Insert the row, or update the existing row on primary-key conflict.

    Equivalent to ``cls(**fields).save(on_conflict="update")``. With an
    autoincrement primary key left unset there is no conflict target, so
    this degrades to a plain INSERT.

    Args:
        **fields: Field values to construct the model.

    Returns:
        The persisted model instance.

    Examples:
        >>> user = await User.upsert(id=1, name="Taylor")
        >>> isinstance(user, User)
        True
    """
    instance = cls(**fields)
    await instance.save(session=session, on_conflict="update")
    return instance

bulk_create(instances, *, using=None, session=None) async classmethod

Persist multiple instances in a single bulk operation

Batch size is unbounded: batches larger than the backend's bind-parameter limit allows in one statement are split internally. The call is atomic either way — inside an ambient transaction() that transaction is the atomicity boundary; a bare call is all-or-nothing (a failure on any row inserts none).

Parameters:

Name Type Description Default
instances list[Self]

Model instances to persist.

required

Returns:

Type Description
int

The number of records inserted.

Examples:

>>> rows = await User.bulk_create([User(name="A"), User(name="B")])
>>> isinstance(rows, int)
True
Source code in src/ferro/models.py
@classmethod
async def bulk_create(
    cls,
    instances: list[Self],
    *,
    using: str | None = None,
    session: "Session | None" = None,
) -> int:
    """Persist multiple instances in a single bulk operation

    Batch size is unbounded: batches larger than the backend's
    bind-parameter limit allows in one statement are split internally.
    The call is atomic either way — inside an ambient ``transaction()``
    that transaction is the atomicity boundary; a bare call is
    all-or-nothing (a failure on any row inserts none).

    Args:
        instances: Model instances to persist.

    Returns:
        The number of records inserted.

    Examples:
        >>> rows = await User.bulk_create([User(name="A"), User(name="B")])
        >>> isinstance(rows, int)
        True
    """
    if not instances:
        return 0
    data = [save_bind_payload(i) for i in instances]
    route = await _transaction_or_using(using, session)
    return await save_bulk_records(cls.__ferro_identity__, data, route)

get_or_create(defaults=None, *, session=None, **fields) async classmethod

Fetch a record by filters or create one when missing

Parameters:

Name Type Description Default
defaults dict[str, Any] | None

Values applied only when creating a new record.

None
**fields

Exact-match filters used for lookup.

{}

Returns:

Type Description
tuple[Self, bool]

A tuple of (instance, created) where created is True for new records.

Examples:

>>> user, created = await User.get_or_create(email="a@b.com")
>>> isinstance(created, bool)
True
Source code in src/ferro/models.py
@classmethod
async def get_or_create(
    cls,
    defaults: dict[str, Any] | None = None,
    *,
    session: "Session | None" = None,
    **fields,
) -> tuple[Self, bool]:
    """Fetch a record by filters or create one when missing

    Args:
        defaults: Values applied only when creating a new record.
        **fields: Exact-match filters used for lookup.

    Returns:
        A tuple of ``(instance, created)`` where ``created`` is True for new records.

    Examples:
        >>> user, created = await User.get_or_create(email="a@b.com")
        >>> isinstance(created, bool)
        True
    """
    query = Query(cls, session=session)
    for key, val in fields.items():
        query = query.where(_field_eq(key, val))

    instance = await query.first()
    if instance:
        return instance, False

    params = {**fields, **(defaults or {})}
    return await cls.create(session=session, **params), True

update_or_create(defaults=None, *, session=None, **fields) async classmethod

Update a matched record or create one when missing

Parameters:

Name Type Description Default
defaults dict[str, Any] | None

Values applied on update or create paths.

None
**fields

Exact-match filters used for lookup.

{}

Returns:

Type Description
tuple[Self, bool]

A tuple of (instance, created) where created is True for new records.

Source code in src/ferro/models.py
@classmethod
async def update_or_create(
    cls,
    defaults: dict[str, Any] | None = None,
    *,
    session: "Session | None" = None,
    **fields,
) -> tuple[Self, bool]:
    """Update a matched record or create one when missing

    Args:
        defaults: Values applied on update or create paths.
        **fields: Exact-match filters used for lookup.

    Returns:
        A tuple of ``(instance, created)`` where ``created`` is True for new records.
    """
    query = Query(cls, session=session)
    for key, val in fields.items():
        query = query.where(_field_eq(key, val))

    instance = await query.first()
    if instance:
        for key, val in (defaults or {}).items():
            setattr(instance, key, val)
        await instance.save(session=session)
        return instance, False

    params = {**fields, **(defaults or {})}
    return await cls.create(session=session, **params), True