Files
Spoolman/spoolman/database/models.py
T
Donkie 505faf3b35 Load entity extra-fields with selectin instead of joined
The spool listing already joins spool -> filament -> vendor, so joined
eager loading of all three `extra` collections multiplied their rows
together. With ~12 spool, ~9 filament and ~9 vendor extra fields, a
single GET /spool?limit=30 made the database materialize ~35k wide rows
(one per field combination) and then spent most of its time in
SQLAlchemy de-duplicating them back into 30 objects.

Switch the three `extra` relationships to lazy="selectin" so each
collection is fetched in its own WHERE id IN (...) query and nothing
multiplies. Measured on a 4911-spool SQLite database, GET /spool?limit=30
drops from 431 ms to 12 ms and limit=100 from 1358 ms to 23 ms, with
byte-identical responses across the spool/filament/vendor/group/search
endpoints and all 371 integration tests green on SQLite, PostgreSQL,
MariaDB and CockroachDB.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
2026-07-20 08:01:22 +02:00

132 lines
5.7 KiB
Python

"""SQLAlchemy data models."""
from datetime import datetime
from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
# The three `extra` collections below MUST NOT go back to lazy="joined".
#
# A spool listing joins spool -> filament -> vendor, so joined eager loading of all
# three `extra` collections multiplies their rows together: with ~12 spool, ~9
# filament and ~9 vendor extra fields, one spool costs ~1000 result rows. Measured
# on a 4911-spool database, a single `GET /spool?limit=30` made SQLite materialize
# 35674 wide rows and took 431 ms (1358 ms at limit=100) — most of it spent in
# SQLAlchemy de-duplicating those rows back into 30 objects.
#
# selectin loading fetches each collection in one extra `WHERE id IN (...)` query
# instead, so nothing multiplies: the same request drops to 12 ms / 23 ms (5
# queries instead of 2) with byte-identical responses.
class Base(AsyncAttrs, DeclarativeBase):
pass
class Vendor(Base):
__tablename__ = "vendor"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
registered: Mapped[datetime] = mapped_column()
name: Mapped[str] = mapped_column(String(64))
empty_spool_weight: Mapped[float | None] = mapped_column(comment="The weight of an empty spool.")
comment: Mapped[str | None] = mapped_column(String(1024))
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
external_id: Mapped[str | None] = mapped_column(String(256))
extra: Mapped[list["VendorField"]] = relationship(
back_populates="vendor",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
)
class Filament(Base):
__tablename__ = "filament"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
registered: Mapped[datetime] = mapped_column()
name: Mapped[str | None] = mapped_column(String(64))
vendor_id: Mapped[int | None] = mapped_column(ForeignKey("vendor.id"))
vendor: Mapped[Optional["Vendor"]] = relationship(back_populates="filaments")
spools: Mapped[list["Spool"]] = relationship(back_populates="filament")
material: Mapped[str | None] = mapped_column(String(64))
price: Mapped[float | None] = mapped_column()
density: Mapped[float] = mapped_column()
diameter: Mapped[float] = mapped_column()
weight: Mapped[float | None] = mapped_column(comment="The filament weight of a full spool (net weight).")
spool_weight: Mapped[float | None] = mapped_column(comment="The weight of an empty spool.")
article_number: Mapped[str | None] = mapped_column(String(64))
comment: Mapped[str | None] = mapped_column(String(1024))
settings_extruder_temp: Mapped[int | None] = mapped_column(comment="Overridden extruder temperature.")
settings_bed_temp: Mapped[int | None] = mapped_column(comment="Overridden bed temperature.")
color_hex: Mapped[str | None] = mapped_column(String(8))
multi_color_hexes: Mapped[str | None] = mapped_column(String(128))
multi_color_direction: Mapped[str | None] = mapped_column(String(16))
external_id: Mapped[str | None] = mapped_column(String(256))
extra: Mapped[list["FilamentField"]] = relationship(
back_populates="filament",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
)
class Spool(Base):
__tablename__ = "spool"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
registered: Mapped[datetime] = mapped_column()
first_used: Mapped[datetime | None] = mapped_column()
last_used: Mapped[datetime | None] = mapped_column()
price: Mapped[float | None] = mapped_column()
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"))
filament: Mapped["Filament"] = relationship(back_populates="spools")
initial_weight: Mapped[float | None] = mapped_column()
spool_weight: Mapped[float | None] = mapped_column()
used_weight: Mapped[float] = mapped_column()
location: Mapped[str | None] = mapped_column(String(64))
lot_nr: Mapped[str | None] = mapped_column(String(64))
comment: Mapped[str | None] = mapped_column(String(1024))
archived: Mapped[bool | None] = mapped_column()
extra: Mapped[list["SpoolField"]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
lazy="selectin",
)
class Setting(Base):
__tablename__ = "setting"
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
last_updated: Mapped[datetime] = mapped_column()
class VendorField(Base):
__tablename__ = "vendor_field"
vendor_id: Mapped[int] = mapped_column(ForeignKey("vendor.id"), primary_key=True, index=True)
vendor: Mapped["Vendor"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class FilamentField(Base):
__tablename__ = "filament_field"
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"), primary_key=True, index=True)
filament: Mapped["Filament"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class SpoolField(Base):
__tablename__ = "spool_field"
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), primary_key=True, index=True)
spool: Mapped["Spool"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())