SQLAlchemy Integration

Granite Storage ships with first-class SQLAlchemy support: a column type that transparently serialises StoredObjectRef to/from JSON, a mixin that adds file-handling methods directly to any ORM model, and an Alembic helper for portable column definitions.

StoredObjectRefType — Column Type

Use StoredObjectRefType as the column type for any column that stores a file reference. It wraps SQLAlchemy’s JSON (PostgreSQL: JSONB) and automatically converts between the column value and a StoredObjectRef instance.

from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from granite_storage import StoredObjectRef, StoredObjectRefType

class Base(DeclarativeBase):
    pass

class UserProfile(Base):
    __tablename__ = "user_profile"

    id: Mapped[str] = mapped_column(String, primary_key=True)
    name: Mapped[str] = mapped_column(String)
    avatar: Mapped[StoredObjectRef | None] = mapped_column(
        StoredObjectRefType, nullable=True
    )

When you read the row back from the database avatar is already a StoredObjectRef (or None). No manual from_dict / to_dict needed.

StoredContentMixin — File-Aware Models

StoredContentMixin adds set_content(), set_content_stream(), get_content(), and delete_content() helpers directly to your model class. You only need to declare two class variables:

  • __stored_content_field_name__ — the attribute that holds the StoredObjectRef.

  • __stored_content_storage_key__ — the policy name to use by default.

Then configure the manager at startup and inject it into the class.

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from granite_storage import (
    StorageManager, StoragePolicy, StoredContentMixin,
    StoredObjectRef, StoredObjectRefType,
)
from granite_storage.backends.local import LocalStorageBackend

class Base(DeclarativeBase):
    pass

class Document(Base, StoredContentMixin):
    __tablename__ = "document"
    __stored_content_field_name__ = "_file_ref"
    __stored_content_storage_key__ = "documents"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column()
    _file_ref: Mapped[StoredObjectRef | None] = mapped_column(
        StoredObjectRefType, nullable=True
    )

# Inject the manager once at startup
manager = StorageManager(
    backends={"local": LocalStorageBackend("/var/uploads")},
    policies={
        "documents": StoragePolicy("documents", "local", max_size=10*1024*1024),
    },
)
Document.__storage_manager__ = manager

# Now any instance can store content
doc = Document(id=1, title="Report")
session.add(doc)
session.flush()  # ensure id is set

doc.set_content(pdf_bytes, filename="report.pdf", content_type="application/pdf")
session.commit()

# Replace existing content
result = doc.replace_content(new_pdf_bytes, filename="report-v2.pdf")
# result.previous_ref  →  the old StoredObjectRef (for cleanup)
# result.new_ref        →  the new StoredObjectRef

# Read back
data = doc.get_content()   # bytes

# Delete stored file (sets _file_ref to None)
doc.delete_content()
session.commit()

Alembic — Portable Column Type

When writing Alembic migrations use portable_storage_ref_type() so that the column is JSON on SQLite and JSONB on PostgreSQL without duplication:

import sqlalchemy as sa
from alembic import op
from granite_storage import portable_storage_ref_type

def upgrade():
    op.add_column(
        "document",
        sa.Column("_file_ref", portable_storage_ref_type(), nullable=True),
    )

def downgrade():
    op.drop_column("document", "_file_ref")

Migrating an Existing Text Column

If you have a column that previously stored inline text and want to migrate it to a StoredObjectRef JSON column on PostgreSQL:

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

def upgrade():
    op.alter_column(
        "section_item",
        "content_markdown",
        existing_type=sa.Text(),
        type_=sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
        postgresql_using=(
            "CASE WHEN content_markdown IS NULL THEN NULL "
            "ELSE jsonb_build_object('legacy_inline_text', content_markdown) END"
        ),
        existing_nullable=True,
    )