Association proxy but for dict keys rather than dict values? #13228
Replies: 1 comment 2 replies
-
the dictionary's "key" and "value" both come from the object and the assoc proxy can proxy any attribute you want, so sure.
to do...what? assoc proxy provides a value. did you want to provide some kind of concatenated value? sure, just add an attribute on your mapped class that does that
no, and there's no reason you'd need to do that, just add a relationship that's a dictionary, you can have as many as you want for the same class.
absolutely
I had Claude take a look and the part where you want to do an "IStr creator" that's unique, without you having to do more verbose intervention each time, requires a pattern like UniqueObject so that you can pull up the appropriate IStr identity given some string value. given prompting based on that I was able to get it to produce a demonstration of all these techniques together, if you want to play with this (it also came up with an interesting way to use """Working example: Gizmo with interned-string-keyed properties.
Uses the UniqueObject recipe
(https://github.com/sqlalchemy/sqlalchemy/wiki/UniqueObject) to
deduplicate IStr instances via a per-session cache in session.info.
The association_proxy creator builds a throwaway IStr(s=k). A
@validates hook on Gizmo.props then calls IStr.as_unique() using
object_session(self) to swap it for the canonical instance, honoring
the unique constraint without any global state or seed_cache step.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy import ForeignKey
from sqlalchemy import select
from sqlalchemy import String
from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import keyfunc_mapping
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import object_session
from sqlalchemy.orm import relationship
from sqlalchemy.orm import Session
from sqlalchemy.orm import validates
class Base(DeclarativeBase):
pass
# ------------------------------------------------------------------
# UniqueObject recipe (from SA wiki, adapted for 2.0-style queries)
# ------------------------------------------------------------------
def _unique(session, cls, hashfunc, queryfunc, constructor, arg, kw):
cache = session.info.setdefault("_unique_cache", {})
key = (cls, hashfunc(*arg, **kw))
if key in cache:
return cache[key]
with session.no_autoflush:
obj = session.scalars(
queryfunc(select(cls), *arg, **kw)
).first()
if obj is None:
obj = constructor(*arg, **kw)
session.add(obj)
cache[key] = obj
return obj
class UniqueMixin:
@classmethod
def unique_hash(cls, *arg, **kw):
raise NotImplementedError()
@classmethod
def unique_filter(cls, query, *arg, **kw):
raise NotImplementedError()
@classmethod
def as_unique(cls, session, *arg, **kw):
return _unique(
session, cls,
cls.unique_hash, cls.unique_filter,
cls, arg, kw,
)
# ------------------------------------------------------------------
# Models
# ------------------------------------------------------------------
class IStr(UniqueMixin, Base):
"""An interned string - unique, deduplicated string value."""
__tablename__ = "interned_string"
id: Mapped[int] = mapped_column(primary_key=True)
s: Mapped[str] = mapped_column(String(30), unique=True)
@classmethod
def unique_hash(cls, s):
return s
@classmethod
def unique_filter(cls, query, s):
return query.where(cls.s == s)
def __repr__(self) -> str:
return f"IStr({self.s!r})"
class Property(Base):
"""A key-value pair associated with a Gizmo."""
__tablename__ = "property"
__table_args__ = (UniqueConstraint("g_id", "k_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
g_id: Mapped[int | None] = mapped_column(
ForeignKey("gizmo.id")
)
k_id: Mapped[int] = mapped_column(
ForeignKey("interned_string.id")
)
v: Mapped[str] = mapped_column(String(30))
gizmo: Mapped[Gizmo | None] = relationship(
back_populates="props",
)
ki: Mapped[IStr] = relationship(lazy="joined")
k: AssociationProxy[str] = association_proxy("ki", "s")
def __repr__(self) -> str:
return f"Property({self.ki!r}={self.v!r})"
class Gizmo(Base):
"""A Gizmo has key-value properties with interned string keys.
Access patterns:
gizmo.props -> dict[str, Property] (keyed by string)
gizmo.p -> dict[str, str] (string -> string)
"""
__tablename__ = "gizmo"
id: Mapped[int] = mapped_column(primary_key=True)
props: Mapped[dict[str, "Property"]] = relationship(
back_populates="gizmo",
collection_class=keyfunc_mapping(
lambda prop: prop.ki.s,
ignore_unpopulated_attribute=True,
),
cascade="all, delete-orphan",
)
p: AssociationProxy[dict[str, str]] = association_proxy(
"props",
"v",
creator=lambda k, v: Property(ki=IStr(s=k), v=v),
)
@validates("props", include_backrefs=False)
def _resolve_istr(self, key, prop):
"""Swap the Property's IStr for the unique instance from the
session cache / database, using object_session(self)."""
session = object_session(self)
if session is not None:
prop.ki = IStr.as_unique(session, s=prop.ki.s)
return prop
def main():
engine = create_engine("sqlite://", echo=True)
Base.metadata.create_all(engine)
with Session(engine) as session:
# Create a Gizmo and set properties via the string proxy.
g = Gizmo()
session.add(g)
g.p["color"] = "red"
g.p["size"] = "large"
g.p["weight"] = "heavy"
session.commit()
print("\n--- After initial commit ---")
print(f"g.p = {dict(g.p)}")
print(f"g.props = {dict(g.props)}")
# Second gizmo reuses existing IStr objects.
g2 = Gizmo()
session.add(g2)
g2.p["color"] = "blue" # reuses IStr("color")
g2.p["shape"] = "round" # new IStr
session.commit()
print("\n--- Second gizmo ---")
print(f"g2.p = {dict(g2.p)}")
# Verify IStr dedup: both gizmos share the same IStr row.
color_1 = g.props["color"].ki
color_2 = g2.props["color"].ki
assert color_1.id == color_2.id, (
"should share the same IStr row"
)
print(
f"\nIStr dedup OK: both use "
f"IStr id={color_1.id} for 'color'"
)
# Update an existing property value (no new IStr created).
g.p["color"] = "green"
session.commit()
print("\n--- After update ---")
print(f"g.p = {dict(g.p)}")
# All interned strings in the DB.
all_istrs = session.scalars(select(IStr)).all()
print("\n--- All interned strings ---")
for i in all_istrs:
print(f" {i}")
# Only 4 unique strings: color, size, weight, shape.
assert len(all_istrs) == 4
if __name__ == "__main__":
main() |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
When used on a dictionary-based relationship, the
association_proxyfunction creates anAssociationProxythat transforms the dictionary's value. My questions:list[Foo]via a dictionary-based association proxy where the dictionary key comes from theFoo.keyattribute that has a unique constraint and the value comes from theFoo.valueattribute)AssociationProxyproxy anotherAssociationProxyattribute?The immediate problem I'm trying to solve is similar to the following:
I could do something like this:
but then
Gizmo.props_str_keyedwould not be synchronized withGizmo.propsorProperty.gizmo. (I need to keepGizmo.propsas-is for now, so replacing it withattribute_keyed_dict("k")is not an option.)Beta Was this translation helpful? Give feedback.
All reactions