Changelog¶
All notable changes to Oxyde are documented here.
0.5.2 - 2026-03-13¶
Bug Fixes¶
create()/save()now validates returned data via Pydantic —RETURNING *results were applied via rawsetattr, bypassing Pydantic validation and type coercion. Additionally,save()on new instances did not update the object with auto-generated fields (PK, defaults) after insertion. Both paths now usemodel_validate().save()on MySQL no longer raisesNotFoundError—save()for existing records always requestedRETURNING, which MySQL doesn't support. Now detects the backend and falls back to affected row count for MySQL.bulk_create()no longer loses fields across rows —_dump_insert_data()usedexclude_none=True, which couldn't distinguish "user didn't set the field" from "user explicitly set None". Changed toexclude_unset=Trueso explicitly passedNoneis preserved while unset fields are omitted (letting DB defaults work).bulk_update()no longer corruptsbytesfields —model_dump(mode="json")converted bytes to base64 strings, which Rust wrote asVARCHARinstead of binary. Now usesmode="python"with_serialize_value_for_ir(), matching the same serialization path asupdate().annotate()rejects invalid expressions — passing an object withoutto_ir()(e.g.annotate(x=object())) was silently ignored. Now raisesTypeError, matching Django's behavior.bulk_create(batch_size=0)raisesValueError— previously crashed with an unhelpfulrange()error. Negative values silently skipped the INSERT. Now validatesbatch_size > 0.- ContextVar transaction leak — child async tasks inherited the parent's transaction scope via
ContextVar. Added_get_owned_entry()ownership check usingasyncio.current_task()so child tasks no longer see the parent's transaction.
Improvements¶
- Streaming query execution — replaced
fetch_all()withfetch()streams in the Rust driver. Rows are now encoded to MessagePack incrementally as they arrive from the database, reducing peak memory usage for large result sets. (core-v0.4.2) - Unified migration ordering —
apply_migrations(),replay_migrations_up_to(), andget_pending_migrations()now use topological sort viadepends_on, matching the behavior already used inreplay_migrations(). Previously these paths used lexicographic sorting only.
Internal¶
- Removed dead
get_migration_order()function fromreplay.py. - Moved local imports to module level in migration integration tests.
0.5.1 - 2026-03-08¶
Bug Fixes¶
Optional["Model"]not detected as FK —Optional["Author"](with a string forward reference) was not recognized as a foreign key becauseForwardRefwas not handled in the metaclass FK detection. Now works correctly alongsideAuthor | Noneandfrom __future__ import annotations.- Python 3.14 compatibility — FK detection relied on reading
namespace["__annotations__"]in the metaclass__new__, which is empty on Python 3.14 (PEP 749: lazy annotations). Moved FK detection after class creation, using Pydantic'smodel_fieldswhich works on all Python versions.
Improvements¶
- Metaclass cleanup — extracted
_build_globalns()and_get_db_attr()helpers, reducing duplication in_resolve_fk_fields()and_parse_field_tags(). Relation field detection usesOxydeFieldInfo.is_virtualproperty.
Documentation¶
- Fixed FK annotation examples — replaced
"Author" | None(TypeError on Python 3.10-3.13) withAuthor | Noneacross all docs. Added a detailed note on type annotations, forward references, andfrom __future__ import annotations.
0.5.0 - 2026-03-07¶
Breaking Changes¶
update() returns row count by default¶
update() now returns int (number of affected rows) instead of list[dict]. This aligns with Django's update() behavior. Pass returning=True to get the previous behavior.
# Before (0.4.x)
rows = await Post.objects.filter(id=42).update(status="published")
# rows was list[dict]
# After (0.5.0)
count = await Post.objects.filter(id=42).update(status="published")
# count is int
rows = await Post.objects.filter(id=42).update(status="published", returning=True)
# rows is list[dict] (explicit opt-in)
execute_to_pylist(), execute_batched_dedup() removed from AsyncDatabase¶
The Python-side batched/dedup execution methods have been removed. JOIN deduplication is now handled entirely in the Rust core encoder. Use execute() for all queries.
batch_size removed from PoolSettings¶
The batch_size parameter is no longer needed since batching is handled by the Rust core.
Bug Fixes¶
distinctignored in aggregates —Count("field", distinct=True)(andSum,Avg) now correctly generatesCOUNT(DISTINCT field). Thedistinctflag was not propagated through the IR.refresh()overwrote virtual fields — callingrefresh()on a model instance withreverse_fkorm2mrelations would overwrite them with raw data from the database. Virtual relation fields are now skipped.timedeltanot deserialized —timedeltacolumns came back as raw integers (microseconds). Now correctly converted back todatetime.timedeltaobjects on the Python side.group_by()produced incorrect SQL — custom group-by implementation replaced with native sea-query group-by, fixing edge cases with table qualification and JOIN queries.union()/union_all()rewritten — custom union SQL generation replaced with native sea-queryUNIONsupport. Fixes ordering and parenthesization issues. Union sub-queries are now built recursively.datetimecast incorrect — fixed datetime value conversion in the Rust driver layer.- Binary data corrupted through serde — binary fields (
bytes) were mangled by theserde_json::Valueintermediate layer. Replaced byrmpv::Valuefor lossless binary round-trip.
Improvements¶
- serde_json eliminated from data path — the entire row encoding pipeline now goes directly from sqlx rows to MessagePack via
rmpv, removing theserde_json::Valueintermediate representation. This fixes binary/timedelta data corruption and reduces allocations. CellEncodertrait — new unified trait inoxyde-driverfor columnar row encoding. Each backend (Postgres, SQLite, MySQL) implementsCellEncoderwith type-specific decoding; generic functions handle the columnar structure.- JOIN dedup moved to Rust — relation deduplication for JOIN queries is now performed in the Rust encoder (
encoder.rs), replacing the Python-sideexecute_batched_deduppath. Results use a compact 3-element msgpack format:[main_columns, main_rows, relations_map]. having()supports annotation aliases —having(total__gt=100)now correctly resolvestotalfromannotate(total=Sum(...))instead of treating it as a model field. Supported lookups:exact,gt,gte,lt,lte.group_by()guards model hydration — calling.all()on agroup_by()query now raisesTypeErrorwith a clear message suggesting.values()or.fetch_all()instead.- Aggregate
DISTINCTsupport —Count,Sum, andAvgnow acceptdistinct=Trueat both the IR and SQL generation levels.Max/Minignore it (asDISTINCTis meaningless for those). - Rust crate modularization —
oxyde-core-py,oxyde-driver, andoxyde-migratemonolithiclib.rsfiles split into focused modules (convert.rs,execute.rs,pool.rs,migration.rs,diff.rs,op.rs,sql.rs, etc.). - Migrations use native sea-query — migration SQL generation now uses sea-query builders instead of hand-crafted SQL strings, improving dialect compatibility.
- Migration code deduplicated — shared utilities (
detect_dialect,load_migration_module,parse_query_result) extracted tooxyde.migrations.utils. - Test suite restructured — tests organized into
unit/,smoke/, andintegration/directories with shared helpers (StubExecuteClient). New integration tests cover CRUD, aggregation, filtering, pagination, relations, transactions, field types, and edge cases against real SQLite. - Free-threaded Python support — CI now builds wheels for Python 3.13t and 3.14t (free-threaded / no-GIL builds).
typerbumped to >= 0.24 — resolves deprecation warnings.
0.4.0 - 2026-02-23¶
Breaking Changes¶
OxydeModel renamed to Model¶
The base class has been renamed from OxydeModel to Model:
# Before (0.3.x)
from oxyde import OxydeModel
class User(OxydeModel):
...
# After (0.4.0+)
from oxyde import Model
class User(Model):
...
Deprecation notice
OxydeModel still works in 0.4.x and emits a DeprecationWarning. It will be removed in a future release. Update your imports now:
Direct import from oxyde.models.base import OxydeModel is not supported and will raise ImportError.
limit() and offset() reject negative values¶
Both methods now raise ValueError on negative input. Previously negative values were silently accepted and produced invalid SQL.
# 0.3.x: silently generated broken SQL
qs.limit(-1)
# 0.4.0: raises ValueError
qs.limit(-1) # ValueError: limit() requires a non-negative value, got -1
ensure_field_metadata() removed¶
The classmethod Model.ensure_field_metadata() has been removed. Model metadata is now finalized eagerly at class definition time. If you were calling this method manually, simply remove the call — it is no longer needed.
resolve_pending_fk() replaced¶
resolve_pending_fk() in oxyde.models.registry has been replaced by:
finalize_pending()— eagerly finalizes all pending modelsassert_no_pending_models()— raisesRuntimeErrorif any models are still pending
migrations.types module removed¶
The oxyde.migrations.types module (validate_sql_type, normalize_sql_type, translate_db_specific_type) has been removed. Type handling is now internal to the Rust core.
Bug Fixes¶
union()/union_all()were silently ignored — union queries now correctly generate SQL and execute as expected.- FK relation fields included in INSERT/UPDATE — fields like
author: Authorwere incorrectly serialized into INSERT/UPDATE statements alongside the actualauthor_idcolumn. Now properly excluded. - Nested JOIN hydration failed — deeply nested joins (e.g.
post__author__profile) could produce incorrect or missing data. Hydration logic rewritten to correctly resolve nested FK references. get_or_create()race condition — concurrent calls creating the same record could both fail withIntegrityError. Now retriesget()on conflict.- Migration advisory lock on wrong connection —
pg_try_advisory_lockandpg_advisory_unlockcould execute on different pool connections. Lock now pins a connection viabegin_transaction(). - Transaction leak on savepoint failure — if savepoint creation failed, transaction depth was already incremented, corrupting state. Depth is now incremented only after successful savepoint creation.
bulk_create([])did not raise — empty list was silently accepted. Now correctly raisesValueError.- Negative pool duration accepted —
PoolSettingsdurations now raiseValueErrorfor negative values. makemigrationssilently continued on broken migrations — if replaying existing migrations failed, the CLI used an empty schema as baseline, potentially generating destructive migrations. Now fails with exit code 1.migratewrong exit code —migratecould exit with code 0 when migration was not found.
Improvements¶
- Eager model finalization — model metadata (field metadata, column types, PK info) is now computed at class definition time instead of lazily on first query. Eliminates an entire class of "metadata not ready" bugs.
- Unified type registry —
TYPE_REGISTRYinoxyde.core.typesconsolidates all Python-to-IR type mappings. Fixesboolbeing misclassified asintin lookups. - Local imports removed — circular dependency issues resolved; all imports are now at module level for better readability and faster import time.