Changelog¶
All notable changes to Oxyde are documented here.
0.6.1 - 2026-03-24¶
Bug Fixes¶
count()andexists()bypassedto_ir()— both methods built their IR directly viair.build_select_ir(), skipping the fullto_ir()pipeline. This meantcol_typesand other query state were not included, which could produce incorrect SQL when type-sensitive filters were involved. Both now go throughto_ir()like all other query methods.save(update_fields)silently ignored FK field names — passing a virtual FK name likeupdate_fields=["author"]was silently accepted but the field was not included in the UPDATE because it didn't match any database column. Now correctly resolves FK names to their synthetic columns (e.g."author"→"author_id"). Virtual relation fields (reverse_fk,m2m) now raiseFieldError.
Internal¶
TQueryTypeVar replaced withSelf— all query mixins (FilteringMixin,PaginationMixin,AggregationMixin,ExecutionMixin,JoiningMixin,DebugMixin) andQuerynow usetyping_extensions.Selffor return-type annotations instead of the customTQueryTypeVar. Fixes mypy errors with the previous approach.
0.6.0 - 2026-03-20¶
Bug Fixes¶
bulk_update()used field names instead ofdb_columnas filter keys — when a model had a customdb_column, the generated SQL referenced the Python field name, which doesn't exist in the database. Now correctly maps field names todb_columnvia model metadata.- Result columns not remapped to field names — Rust returns
db_columnnames (e.g.author_id), but Python expects field names (author). Addedreverse_column_maptoModelMetaand_remap_columns()in the execution layer. Affects all result formats: dedup, columnar, and row dicts. Decimalbound as string on PostgreSQL —Decimalvalues were serialized as strings, causing type mismatches in comparisons. Now bound natively viarust_decimal::Decimal.TIMESTAMPTZnot decoded correctly — timezone-aware datetime columns lost timezone info during encoding. Now preserves timezone in RFC3339 format.datetimestrings falsely parsed as datetime — ISO 8601 strings stored inVARCHAR/TEXTcolumns (e.g."2024-01-15T12:30:00Z") were auto-parsed into datetime values. Type hints now prevent this: ifcol_typeis"str"or"TEXT", the value stays as a string.timedeltaencoded as integer microseconds — timedelta values were exposed as rawi64microseconds. Now correctly converted tof64seconds at the driver layer, matching Python'stimedeltarepresentation.- Type-unsafe filter binding — filter values were converted without consulting column type hints, leading to implicit string coercion (e.g.
age = '18'instead ofage = 18). Filter builder now receivescol_typesand binds values with correct SQL types. DELETEpath ignored type hints — value conversion for DELETE queries did not passcol_types, causing the same coercion issues as filters. Fixed.
New Features¶
- PostgreSQL array support — native support for
int[],str[],uuid[],bool[],float[],decimal[],datetime[]columns. Arrays are correctly bound as parameters, decoded from results (including NULL elements), and mapped in migrations (BIGINT[]on Postgres,JSONon MySQL,TEXTon SQLite). - JSON column support —
dictfields are now mapped toJSONB(Postgres),JSON(MySQL),TEXT(SQLite) in migrations. Values are bound asValue::Jsonin queries. .sql(with_types=True)— new optional parameter shows exact SQL type tags for each bound parameter:[("BigInt", 18), ("Uuid", "550e...")]. Useful for debugging type binding issues.
Improvements¶
- Centralized type classification in Rust — new
classify_type()function as single source of truth for mapping IR type names and SQL type names to semantic categories. Replaces scattered match arms across the codebase. - Typed NULL values —
NULLparameters now carry their column type (e.g.Value::Uuid(None)instead of genericValue::String(None)), preventing type mismatch errors on strict databases. BIGSERIALfor integer PKs on PostgreSQL — integer primary keys now map toBIGSERIALinstead ofSERIAL, supporting larger sequences out of the box.reverse_column_mapcached onModelMeta—db_column → field_namemapping is computed once at model finalization, not on every query.
Internal¶
- Rust core bumped to 0.5.0 (core-v0.5.0).
- Removed
_convert_timedelta_columns()from Python execution layer (moved to Rust driver). - Removed field_name aliasing from Rust SELECT; column remapping now happens in Python via
reverse_column_map. - Unified
_get_python_type_nameto delegate toget_ir_type. AddedjsonandT[]patterns to Rustpython_type_to_sql. - Added
test_type_bindingtest suite covering type classification, array handling, typed nulls, and edge cases. - Added datetime filtering integration tests.
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.