Skip to content

Coerce loose input to canonical types on the create paths#19

Merged
vsdudakov merged 1 commit into
mainfrom
fix/create-type-coercion
Jul 16, 2026
Merged

Coerce loose input to canonical types on the create paths#19
vsdudakov merged 1 commit into
mainfrom
fix/create-type-coercion

Conversation

@vsdudakov

Copy link
Copy Markdown
Owner

Problem

create() returned instances carrying raw strings where get() returns typed values: create(balance="12.34", token="<uuid string>").balance was a str, while re-fetching the same row yielded Decimal/UUID.

Root cause: every create-family entry point (create, get_or_create, update_or_create, bulk_create, and the QuerySet variants) builds instances through Model.__init__, which normalises kwargs via to_python_value only for fields that override it — previously just DatetimeField/DateField/TimeField. The update_or_create update path (update_from_dict) had no coercion at all.

Fix

  • Add to_python_value overrides to DecimalField, UUIDField, BooleanField (delegating to to_db, since bool("false") would be True), TimeDeltaField, IntEnumField and CharEnumField.
  • JSONField is deliberately untouched — a string is a valid JSON value, so parsing it on assignment would change semantics. Int/float/char fields stay identity to preserve the all-plain-model construction fast path (no virtual call per column).
  • update_from_dict now applies the same coercion through meta.coerced_fields, so update_or_create(defaults={"balance": "99.50"}) also leaves a Decimal on the instance.

Tests

New tests/test_create_type_coercion.py, parametrized across all six backends via the db fixture: a unit test for each new coercion (including None and canonical-value passthrough) plus DB round-trips for create, get_or_create (both paths), update_or_create (both paths), bulk_create and the QuerySet-level get_or_create/update_or_create, each comparing the returned instance's types against what get() yields.

Verified locally: new test passes on sqlite + postgres (other backends skip without reachable servers); full suite green (1215 sqlite under xdist + serial postgres modules); make lint clean; coverage unchanged vs main (identical missed-line set, all from backend-specific code needing live mysql/mariadb/oracle/mssql servers).

create()/get_or_create()/update_or_create()/bulk_create() build instances
through Model.__init__, which normalises kwargs via to_python_value only for
fields that override it — previously just Datetime/Date/Time. A loose value
("12.34" for a decimal column, a hex string for a UUID column) therefore
stayed a raw string on the returned instance, while get() decoded the same
row to Decimal/UUID via to_python.

- Add to_python_value overrides to DecimalField, UUIDField, BooleanField
  (via to_db, since bool("false") would be True), TimeDeltaField,
  IntEnumField and CharEnumField. JSONField is left alone (a string is a
  valid JSON value) and int/float/char stay identity to keep the plain
  construction fast path.
- update_from_dict (the update_or_create update path) now applies the same
  coercion through meta.coerced_fields.
- New tests/test_create_type_coercion.py runs on every configured backend
  and checks all create-family entry points against get()'s types.
@vsdudakov
vsdudakov merged commit 21d7be2 into main Jul 16, 2026
4 checks passed
@vsdudakov
vsdudakov deleted the fix/create-type-coercion branch July 16, 2026 07:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant