On Mon, Nov 19, 2018 at 5:27 PM Eric Smith <e...@esmithy.net> wrote: > > Thanks -- the "before_compile" event seems interesting -- that could solve > automatically adding a tenant on the SELECT side of things.
note that I've rewritten the PreFilteredQuery and GloblalFilter recipes entirely to use before_compile: https://bitbucket.org/zzzeek/sqlalchemy/wiki/UsageRecipes/FilteredQuery However, FilteredQuery doesn't handle all SQL, just ORM queries, and even then it has some caveats. There's ways to ensure a row-based tenant is present in all kinds of queries but they're complicated and there will always be more edge cases where something needs special attention. In SQLAlchemy, we have a feature called "single-table inheritance" which has many similarities, in that a certain column is a discriminator for rows, and this discriminator has to be present at all times. It's complicated and there have been many issues (particularly joins) where it needed additional adjustment. > What I wish for is a usage pattern like: > > When a web request comes in, determine the appropriate tenant > Set the tenant somewhere associated with the context of the request > Use the database as if it were single-tenant, relying on event > handlers/subclassed ORM objects/something to fill in the tenant for INSERT > and SELECT statements. That's the pattern all of these techniques are trying to go for. > > Does that seem realistic? For row-based tenancy you can do it but you would need to test every new feature very carefully to make sure you aren't losing the tenant for a certain row nor are you creating queries that are wrong. Below is a LEFT OUTER JOIN that is used to try to find rows in "a" that don't have a row in "b". This query should be rewritten to use a NOT EXISTS subquery. However, this kind of query is common: SELECT a.id LEFT OUTER JOIN b ON a.id=b.a_id WHERE b.id IS NULL if we add logic that naively adds tenant IDs to everything: SELECT a.id LEFT OUTER JOIN b ON a.id=b.a_id WHERE b.id IS NULL WHERE a.tenant_id=5 AND b.tenant_id=5 now the query won't work. You'll get no rows for "a". the better query is: SELECT a.id FROM a WHERE NOT EXISTS(SELECT 1 FROM b WHERE a.id=b.a_id WHERE b.tenant_id=5) AND a.tenant_id=5 Overall, the scheme that's presented in the schema_translate_map feature is the one that definitely works in all cases. But if you have millions of "tenants", that's not really multitenancy IMHO, that's user accounts. > > For schema-per-tenant, I can image how that would be done with > schema_translate_map or search_path, since that covers both INSERT and > SELECT. For row-per-tenant, is there something roughly equivalent to > before_compile to be able to modify INSERT statements to include a tenant? > There's the engine before_execute event -- maybe that would work -- but > someone in this group has said those are evil. before_execute() and before_cursor_execute are your two events, and since you'd want to intercept insert() structures before they are compiled, you'd be using before_execute(). It's doable but you have to test a lot. But it's also not necessary if you're fine sticking to ORM only. > > The "MultiAlchemy" example I linked to earlier creates a Session subclass > overriding the "add" method to automatically include the tenant, but it seems > like that would miss objects that get added implicitly through relationships. If you want to do it at the ORM level only, for persistence there are plenty of events you can use: before_insert: https://docs.sqlalchemy.org/en/latest/orm/events.html?highlight=before_insert#sqlalchemy.orm.events.MapperEvents.before_insert after_attach (or before_attach): https://docs.sqlalchemy.org/en/latest/orm/events.html?highlight=after_attach#sqlalchemy.orm.events.SessionEvents.after_attach init: https://docs.sqlalchemy.org/en/latest/orm/events.html?highlight=init#sqlalchemy.orm.events.InstanceEvents.init Above, the "init" method is the earliest, and it is really just another way to do the __init__() constructor on your object. that's the event that polymorphic persistence uses and is likely the simplest to use, because you have your tenant id set up front. But if the tenant id is only associated with the Session then use before_attach/after_attach. > > Finally, a specific question about before_compile. If the query I want to > modify is for first(), changing the query fails with: > > sqlalchemy.exc.InvalidRequestError: Query.filter() being called on a Query > which already has LIMIT or OFFSET applied. To modify the row-limited results > of a Query, call from_self() first. Otherwise, call filter() before limit() > or offset() are applied. right you'll see in the wiki examples you have to call a special method query.enable_assertions(False) which is explicitly for writing these recipes. don't use from_self(), that gives you a much more complicated query and is not appropriate here. > > If I use from_self(), I get: > > RecursionError: maximum recursion depth exceeded while calling a Python object > > > I guess because from_self causes the event handler to be fired again. I could > use some kind of flag to avoid that, but needing to do so makes me wonder if > I'm doing something wrong? Example: > > > from sqlalchemy import create_engine, Column, Integer, String, event, Boolean > from sqlalchemy.ext.declarative import declarative_base > from sqlalchemy.orm import sessionmaker, Query > > engine = create_engine('sqlite:///:memory:', echo=True) > Base = declarative_base() > Session = sessionmaker(bind=engine) > > > class User(Base): > __tablename__ = 'users' > > id = Column(Integer, primary_key=True) > name = Column(String) > fullname = Column(String) > password = Column(String) > deleted = Column(Boolean, default=False) > > > @event.listens_for(Query, "before_compile", retval=True) > def no_deleted(query): > for desc in query.column_descriptions: > if desc['type'] is User: > entity = desc['entity'] > query = query.from_self().filter(entity.deleted == False) > return query > > > Base.metadata.create_all(engine) > session = Session() > ed_user = User(name='ed', fullname='Ed Jones', password='edspassword') > session.add(ed_user) > session.commit() > > any_not_deleted_user = session.query(User).first() > > > Thanks again for the guidance, > > Eric > > > > -- > SQLAlchemy - > The Python SQL Toolkit and Object Relational Mapper > > http://www.sqlalchemy.org/ > > To post example code, please provide an MCVE: Minimal, Complete, and > Verifiable Example. See http://stackoverflow.com/help/mcve for a full > description. > --- > You received this message because you are subscribed to the Google Groups > "sqlalchemy" group. > To unsubscribe from this group and stop receiving emails from it, send an > email to sqlalchemy+unsubscr...@googlegroups.com. > To post to this group, send email to sqlalchemy@googlegroups.com. > Visit this group at https://groups.google.com/group/sqlalchemy. > For more options, visit https://groups.google.com/d/optout. -- SQLAlchemy - The Python SQL Toolkit and Object Relational Mapper http://www.sqlalchemy.org/ To post example code, please provide an MCVE: Minimal, Complete, and Verifiable Example. See http://stackoverflow.com/help/mcve for a full description. --- You received this message because you are subscribed to the Google Groups "sqlalchemy" group. To unsubscribe from this group and stop receiving emails from it, send an email to sqlalchemy+unsubscr...@googlegroups.com. To post to this group, send email to sqlalchemy@googlegroups.com. Visit this group at https://groups.google.com/group/sqlalchemy. For more options, visit https://groups.google.com/d/optout.