Skip to content

ORM: callable field defaults not resolved + save() can't INSERT natural (non-auto-increment) primary keys #50

Description

@andrevanzuydam

Two related ORM bugs found while running a downstream PostgreSQL service against tina4-python 3.13.1. Both break basic ORM use for common model shapes (per-row timestamp defaults, user-supplied string primary keys). A downstream BaseORM shim works around both, but the proper fix belongs in the framework.

Per the project's parity rules, both should be fixed across all 4 frameworks (Python, PHP, Ruby, Node.js) with tests for each; Python is the master reference.


Bug 1 — Callable field defaults are not resolved

A field declared with a callable default stores the function object itself instead of its return value:

class GiftCard(ORM):
    created_at = DateTimeField(default=lambda: datetime.datetime.now())

On save this reaches the driver unresolved and fails:

psycopg2.ProgrammingError: can't adapt type 'function'

Root cause (Python):

  • orm/model.py, model __init__: setattr(self, name, field.default) (~L182) assigns the default verbatim.
  • orm/fields.py, Field.validate(): return self.default (~L59) — neither checks callable(...).

Expected: when default is callable, call it at the point of use so each instance gets a freshly evaluated value (e.g. a distinct per-row created_at).

Repro:

gc = GiftCard()
type(gc.created_at)   # -> <class 'function'>  (should be datetime)
gc.save()             # -> raises "can't adapt type 'function'"

Bug 2 — save() cannot INSERT a row with a natural (non-auto-increment) primary key

save() decides INSERT vs UPDATE purely on whether the PK is set:

# orm/model.py, save() (~L320)
if pk_value is not None:
    db.update(...)      # UPDATE
else:
    db.insert(...)      # INSERT

This only works for auto-increment keys. For a model whose primary key is a user-supplied/natural key (e.g. a string gift_card_number set before the first save), the PK is always set, so save() issues an UPDATE that matches zero rows and silently persists nothing — while still returning success.

Repro:

gc = GiftCard()
gc.gift_card_number = "GC-100"   # natural string PK, set by caller
gc.created_by_email = "a@b.com"
gc.owned_by_email = "a@b.com"
gc.save()                         # returns success...
GiftCard.find_by_id("GC-100")     # ...but returns None — nothing was inserted

Expected: for a non-auto-increment PK, decide INSERT vs UPDATE by whether the row exists (the ORM already has exists() / find_by_id()), or track a persisted flag. Auto-increment behaviour must be unchanged. Saving a new natural-key row must INSERT and be retrievable; saving again must UPDATE (no duplicate, no error).


Downstream workaround (for reference)

class BaseORM(ORM):
    def __init__(self, data=None, **kwargs):
        super().__init__(data, **kwargs)
        for name in self._fields:
            value = getattr(self, name, None)
            if callable(value):
                setattr(self, name, value())   # Bug 1

    def save(self):
        pk = self._get_pk()
        pk_value = getattr(self, pk, None)
        pk_field = self._fields[pk]
        if pk_value is not None and not pk_field.auto_increment and not type(self).exists(pk_value):
            return self._insert_new()          # Bug 2: force INSERT for new natural-key rows
        return super().save()

Acceptance criteria

  • Callable defaults are invoked (distinct per-instance values); covered by a test.
  • save() INSERTs a new natural-key row and UPDATEs an existing one; covered by tests.
  • Auto-increment PK behaviour unchanged.
  • Parity: fixed with tests in Python, PHP, Ruby, and Node.js.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions