Skip to main content

Typed JSON fields for SQLAlchemy with automatic mutation tracking

Project description

sqlatypemodel

Tests PyPI version Python versions

Typed JSON fields for SQLAlchemy with automatic mutation tracking

sqlatypemodel solves the "immutable JSON" problem in SQLAlchemy. It allows you to use strictly typed Python objects (Pydantic, Dataclasses, Attrs) as database columns while ensuring that every change—no matter how deep—is automatically saved.

Powered by orjson for blazing-fast performance and featuring a State-Based Architecture for universal compatibility.


✨ Key Features

  • 🏗️ State-Based Tracking (v0.8.0):

    • Universal Compatibility: Works natively with unhashable objects (e.g., standard Pydantic models, eq=True Dataclasses).
    • Zero Monkey-Patching: No longer alters your class's __hash__ or __eq__ methods. Uses internal MutableState tokens for safe identity tracking.
  • 🐢 -> 🐇 Lazy Loading:

    • Zero-cost loading: Objects loaded from the DB are raw Python dicts until you access them.
    • JIT Wrapping: Wrappers are created Just-In-Time.
  • 🥒 Pickle & Celery Ready:

    • Full support for pickle. Pass your database models directly to Celery workers or cache them in Redis.
    • Tracking is automatically restored upon deserialization via MutableMethods.
  • 🚀 High Performance:

    • Powered by orjson: faster serialization than standard json.
    • Native Types: Supports datetime, UUID, and numpy out of the box.
    • Smart Caching: Introspection results are cached (O(1) overhead).
  • 🔄 Deep Mutation Tracking:

    • Detects changes like user.settings.tags.append("new") automatically.
    • No more flag_modified() or reassigning the whole object.

The Problem

By default, SQLAlchemy considers JSON columns immutable unless you replace the entire object.

# ❌ NOT persisted by default in SQLAlchemy
user.settings.theme = "dark"
user.settings.tags.append("new")

session.commit() # Nothing happens! Data is lost.

The Solution

With sqlatypemodel, in-place mutations are tracked automatically:

# ✅ Persisted automatically
user.settings.theme = "dark"
user.settings.tags.append("new")

session.commit() # UPDATE "users" SET settings = ...

Installation

pip install sqlatypemodel

To ensure you have orjson (recommended):

pip install sqlatypemodel[fast]

Quick Start (Pydantic)

1. Standard Usage (MutableMixin)

Best for write-heavy workflows or when you always access the data immediately.

from typing import List
from pydantic import BaseModel, Field
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlatypemodel import ModelType, MutableMixin
from sqlatypemodel.util.sqlalchemy import create_engine

# 1. Define Pydantic Model (Inherit from MutableMixin)
class UserSettings(MutableMixin, BaseModel):
    theme: str = "light"
    tags: List[str] = Field(default_factory=list)

# 2. Define Entity
class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    settings: Mapped[UserSettings] = mapped_column(ModelType(UserSettings))

# 3. Usage
# Use our helper to get free orjson configuration
engine = create_engine("sqlite:///") 
Base.metadata.create_all(engine)

with Session(engine) as session:
    user = User(settings=UserSettings())
    session.add(user)
    session.commit()

    # Mutation works!
    user.settings.tags.append("python") 
    session.commit() 

🔧 Internal Magic:

The library uses __init_subclass__ to automate the connection between your models and the SQLAlchemy ModelType.

class BaseMutableMixin(MutableMethods, Mutable, abc.ABC):
    def __init_subclass__(cls, **kwargs: Any) -> None:
        # Automatically calls ModelType.register_mutable(cls)
        from sqlatypemodel.model_type import ModelType
        ModelType.register_mutable(cls)

What this means for you:

  • Zero Configuration: Just inherit, and the model is ready for tracking.
  • auto_register=False: Use this flag if you want to define a base class for your models but don't want it globally registered yet.

2. High-Performance Usage (LazyMutableMixin)

Recommended for read-heavy applications. Objects are initialized "lazily". The overhead of change tracking is only paid when you actually access the attribute.

from sqlatypemodel import LazyMutableMixin

# Just swap MutableMixin -> LazyMutableMixin
class UserSettings(LazyMutableMixin, BaseModel):
    theme: str = "light"
    # ...

Performance Comparison (Load 5,000 objects):

  • Standard (MutableMixin): ~1100ms
  • Lazy (LazyMutableMixin): ~7ms (~150x faster)

🛠 Advanced Support: Attrs, Dataclasses, Plain Classes

sqlatypemodel isn't just for Pydantic. It supports any Python class.

1. Python Dataclasses

In v0.8.0+, standard dataclasses work out of the box, even if they are unhashable (eq=True, frozen=False).

However, for deep recursion safety during initialization on Python 3.12+, we still recommend our safe wrapper:

from dataclasses import asdict
from typing import Any
from sqlatypemodel import MutableMixin, ModelType
# ✅ Safe wrapper (prevents recursion loops during init)
from sqlatypemodel.util.dataclasses import dataclass 

@dataclass
class DataConfig(MutableMixin):
    host: str
    port: int
    meta: dict[str, Any]

# SQLAlchemy Mapping
col: Mapped[DataConfig] = mapped_column(
    ModelType(
        DataConfig,
        dumper=asdict,
        loader=lambda d: DataConfig(**d)
    )
)

2. Attrs

Standard attrs classes are fully supported.

from attrs import asdict, define
from sqlatypemodel import MutableMixin, ModelType

@define 
class AttrsConfig(MutableMixin):
    retries: int
    tags: list[str]

# Mapping
col = mapped_column(
    ModelType(
        AttrsConfig,
        dumper=asdict,
        loader=lambda d: AttrsConfig(**d)
    )
)

🔧 Under the Hood: Architecture

1. State-Based Tracking (The "Safe" Way)

Unlike other libraries that require your objects to be hashable (often breaking Pydantic/Dataclasses), sqlatypemodel attaches a lightweight State Token (MutableState) to every tracked object.

  • Parent holds the _state token strongly.
  • Children track their parents via WeakKeyDictionary[_state, attribute_name].
  • Result: Robust tracking that survives Garbage Collection race conditions and works with any Python object.

2. Logic Flow: Change Tracking (The "Bubble Up" Effect)

When you modify a deeply nested list, the signal bubbles up to SQLAlchemy using these tokens.

User Code:  user.settings.tags.append("new")
                      |
                      v
[Leaf]      MutableList.append("new")
                      |
            (triggers self.changed())
                      |
                      v
[Logic]     sqlatypemodel.events.safe_changed()
                      |
            1. Iterates `self._parents` (WeakKeyDictionary)
            2. Resolves `MutableState` -> Parent Object (UserSettings)
                      |
                      v
[Parent]    UserSettings.changed()
                      |
            (triggers safe_changed() recursively)
                      |
            1. Resolves `MutableState` -> Parent Object (User Entity)
                      |
                      v
[Root]      SQLAlchemy Model (User)
                      |
            flag_modified(user, "settings") -> Marks row as "Dirty"

⚠️ Important Caveats

1. 64-bit Integer Limit

orjson (Rust) is strict. It supports signed 64-bit integers (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807). If you try to save a Python int larger than this, the library automatically falls back to the standard json library, ensuring data safety at the cost of performance for that specific record.

2. Mixed Types in Collections

While supported, avoid mixing complex mutable types in the same list (e.g., [MyModel(), {"key": "val"}]) if you can. It works, but the "Lazy" loading mechanism has to infer types at runtime, which is slightly slower than uniform lists.


📊 Benchmark Performance Summary

Name (time in µs) Min Max Mean StdDev Median IQR OPS (Kops/s) Rounds
test_benchmark_db_load_lazy 1.4020 23.7250 1.5317 0.3680 1.4920 0.0400 652.8531 48499
test_benchmark_read_access_lazy_cached 3.8570 39.9650 4.1546 0.6319 4.0680 0.0890 240.6966 59698
test_benchmark_db_load_eager 498.4650 646.1940 523.9364 14.2668 521.3440 16.8730 1.9086 1114

🔑 Key Definitions

  • Min/Max/Mean/Median: The recorded time for the operations in microseconds (µs).
  • StdDev: Standard Deviation from the Mean.
  • IQR: InterQuartile Range (difference between the 75th and 25th percentiles).
  • OPS: Operations Per Second (calculated as ).
  • Rounds: The number of times the benchmark was executed to collect data.

📝 Execution Overview

  • Platform: Linux (Python 3.14.2)
  • Total Tests Collected: 51
  • Status: 51 Passed
  • Total Duration: 5.89 seconds

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sqlatypemodel-0.8.0.tar.gz (23.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sqlatypemodel-0.8.0-py3-none-any.whl (26.8 kB view details)

Uploaded Python 3

File details

Details for the file sqlatypemodel-0.8.0.tar.gz.

File metadata

  • Download URL: sqlatypemodel-0.8.0.tar.gz
  • Upload date:
  • Size: 23.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.2.1 CPython/3.14.2 Linux/6.17.11-300.fc43.x86_64

File hashes

Hashes for sqlatypemodel-0.8.0.tar.gz
Algorithm Hash digest
SHA256 e326c0d77d6c972296bb88c882594e5b179d797b6d63fd5ace26990bf05a9f8f
MD5 0e32d6626d353cc03f488e7ee1020140
BLAKE2b-256 c7a0799eb3f32f4211d17f1ad13850884f7ab16937fb7d57afefff6eaf8dae65

See more details on using hashes here.

File details

Details for the file sqlatypemodel-0.8.0-py3-none-any.whl.

File metadata

  • Download URL: sqlatypemodel-0.8.0-py3-none-any.whl
  • Upload date:
  • Size: 26.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.2.1 CPython/3.14.2 Linux/6.17.11-300.fc43.x86_64

File hashes

Hashes for sqlatypemodel-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cbc201adeeb4ee8fc79bb778d64ba316ed1d29705e7cf3d1c76191833bc50b34
MD5 0627b567c86e0971d4de3c7b3394b805
BLAKE2b-256 8a253f4dbb4b099509187f446e0be1bb0922464a140c16588bed6b706628aef8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page