From: Mike Bayer Date: Wed, 24 Nov 2010 17:21:59 +0000 (-0500) Subject: - SqlSoup overhaul X-Git-Tag: rel_0_6_6~31^2~11 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=c848624f9db325213ddf2fde9819946f9eb235cd;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git - SqlSoup overhaul - Added "map_to()" method to SqlSoup, which is a "master" method which accepts explicit arguments for each aspect of the selectable and mapping, including a base class per mapping. [ticket:1975] - Mapped selectables used with the map(), with_labels(), join() methods no longer put the given argument into the internal "cache" dictionary. Particularly since the join() and select() objects are created in the method itself this was pretty much a pure memory leaking behavior. --- diff --git a/CHANGES b/CHANGES index a551cfc28b..5bf836ac02 100644 --- a/CHANGES +++ b/CHANGES @@ -98,6 +98,18 @@ CHANGES - declarative - An error is raised if __table_args__ is not in tuple or dict format, and is not None. [ticket:1972] + +- sqlsoup + - Added "map_to()" method to SqlSoup, which is a "master" + method which accepts explicit arguments for each aspect of + the selectable and mapping, including a base class per + mapping. [ticket:1975] + + - Mapped selectables used with the map(), with_labels(), + join() methods no longer put the given argument into the + internal "cache" dictionary. Particularly since the + join() and select() objects are created in the method + itself this was pretty much a pure memory leaking behavior. 0.6.5 ===== diff --git a/doc/build/orm/extensions/sqlsoup.rst b/doc/build/orm/extensions/sqlsoup.rst index fcc937166a..7b5ab1e4b6 100644 --- a/doc/build/orm/extensions/sqlsoup.rst +++ b/doc/build/orm/extensions/sqlsoup.rst @@ -2,5 +2,10 @@ SqlSoup ======= .. automodule:: sqlalchemy.ext.sqlsoup + + +SqlSoup API +------------ + +.. autoclass:: sqlalchemy.ext.sqlsoup.SqlSoup :members: - diff --git a/lib/sqlalchemy/ext/sqlsoup.py b/lib/sqlalchemy/ext/sqlsoup.py index e8234e7c77..3cca2c93fb 100644 --- a/lib/sqlalchemy/ext/sqlsoup.py +++ b/lib/sqlalchemy/ext/sqlsoup.py @@ -2,19 +2,20 @@ Introduction ============ -SqlSoup provides a convenient way to access existing database tables without -having to declare table or mapper classes ahead of time. It is built on top of -the SQLAlchemy ORM and provides a super-minimalistic interface to an existing -database. - -SqlSoup effectively provides a coarse grained, alternative interface to -working with the SQLAlchemy ORM, providing a "self configuring" interface -for extremely rudimental operations. It's somewhat akin to a -"super novice mode" version of the ORM. While SqlSoup can be very handy, -users are strongly encouraged to use the full ORM for non-trivial applications. +SqlSoup provides a convenient way to access existing database +tables without having to declare table or mapper classes ahead +of time. It is built on top of the SQLAlchemy ORM and provides a +super-minimalistic interface to an existing database. + +SqlSoup effectively provides a coarse grained, alternative +interface to working with the SQLAlchemy ORM, providing a "self +configuring" interface for extremely rudimental operations. It's +somewhat akin to a "super novice mode" version of the ORM. While +SqlSoup can be very handy, users are strongly encouraged to use +the full ORM for non-trivial applications. Suppose we have a database with users, books, and loans tables -(corresponding to the PyWebOff dataset, if you're curious). +(corresponding to the PyWebOff dataset, if you're curious). Creating a SqlSoup gateway is just like creating an SQLAlchemy engine:: @@ -39,53 +40,73 @@ Loading objects is as easy as this:: >>> users = db.users.all() >>> users.sort() >>> users - [MappedUsers(name=u'Joe Student',email=u'student@example.edu',password=u'student',classname=None,admin=0), MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu',password=u'basepair',classname=None,admin=1)] + [ + MappedUsers(name=u'Joe Student',email=u'student@example.edu', + password=u'student',classname=None,admin=0), + MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu', + password=u'basepair',classname=None,admin=1) + ] Of course, letting the database do the sort is better:: >>> db.users.order_by(db.users.name).all() - [MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu',password=u'basepair',classname=None,admin=1), MappedUsers(name=u'Joe Student',email=u'student@example.edu',password=u'student',classname=None,admin=0)] + [ + MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu', + password=u'basepair',classname=None,admin=1), + MappedUsers(name=u'Joe Student',email=u'student@example.edu', + password=u'student',classname=None,admin=0) + ] Field access is intuitive:: >>> users[0].email u'student@example.edu' -Of course, you don't want to load all users very often. Let's add a -WHERE clause. Let's also switch the order_by to DESC while we're at -it:: +Of course, you don't want to load all users very often. Let's +add a WHERE clause. Let's also switch the order_by to DESC while +we're at it:: >>> from sqlalchemy import or_, and_, desc >>> where = or_(db.users.name=='Bhargan Basepair', db.users.email=='student@example.edu') >>> db.users.filter(where).order_by(desc(db.users.name)).all() - [MappedUsers(name=u'Joe Student',email=u'student@example.edu',password=u'student',classname=None,admin=0), MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu',password=u'basepair',classname=None,admin=1)] + [ + MappedUsers(name=u'Joe Student',email=u'student@example.edu', + password=u'student',classname=None,admin=0), + MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu', + password=u'basepair',classname=None,admin=1) + ] -You can also use .first() (to retrieve only the first object from a query) or -.one() (like .first when you expect exactly one user -- it will raise an -exception if more were returned):: +You can also use .first() (to retrieve only the first object +from a query) or .one() (like .first when you expect exactly one +user -- it will raise an exception if more were returned):: >>> db.users.filter(db.users.name=='Bhargan Basepair').one() - MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu',password=u'basepair',classname=None,admin=1) + MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu', + password=u'basepair',classname=None,admin=1) Since name is the primary key, this is equivalent to >>> db.users.get('Bhargan Basepair') - MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu',password=u'basepair',classname=None,admin=1) + MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu', + password=u'basepair',classname=None,admin=1) This is also equivalent to >>> db.users.filter_by(name='Bhargan Basepair').one() - MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu',password=u'basepair',classname=None,admin=1) + MappedUsers(name=u'Bhargan Basepair',email=u'basepair@example.edu', + password=u'basepair',classname=None,admin=1) -filter_by is like filter, but takes kwargs instead of full clause expressions. -This makes it more concise for simple queries like this, but you can't do -complex queries like the or\_ above or non-equality based comparisons this way. +filter_by is like filter, but takes kwargs instead of full +clause expressions. This makes it more concise for simple +queries like this, but you can't do complex queries like the +or\_ above or non-equality based comparisons this way. Full query documentation ------------------------ Get, filter, filter_by, order_by, limit, and the rest of the -query methods are explained in detail in :ref:`ormtutorial_querying`. +query methods are explained in detail in +:ref:`ormtutorial_querying`. Modifying objects ================= @@ -96,12 +117,12 @@ Modifying objects is intuitive:: >>> user.email = 'basepair+nospam@example.edu' >>> db.commit() -(SqlSoup leverages the sophisticated SQLAlchemy unit-of-work code, so -multiple updates to a single object will be turned into a single -``UPDATE`` statement when you commit.) +(SqlSoup leverages the sophisticated SQLAlchemy unit-of-work +code, so multiple updates to a single object will be turned into +a single ``UPDATE`` statement when you commit.) -To finish covering the basics, let's insert a new loan, then delete -it:: +To finish covering the basics, let's insert a new loan, then +delete it:: >>> book_id = db.books.filter_by(title='Regional Variation in Moss').first().id >>> db.loans.insert(book_id=book_id, user_name=user.name) @@ -111,14 +132,12 @@ it:: >>> db.delete(loan) >>> db.commit() -You can also delete rows that have not been loaded as objects. Let's -do our insert/delete cycle once more, this time using the loans -table's delete method. (For SQLAlchemy experts: note that no flush() -call is required since this delete acts at the SQL level, not at the -Mapper level.) The same where-clause construction rules apply here as -to the select methods. - -:: +You can also delete rows that have not been loaded as objects. +Let's do our insert/delete cycle once more, this time using the +loans table's delete method. (For SQLAlchemy experts: note that +no flush() call is required since this delete acts at the SQL +level, not at the Mapper level.) The same where-clause +construction rules apply here as to the select methods:: >>> db.loans.insert(book_id=book_id, user_name=user.name) MappedLoans(book_id=2,user_name=u'Bhargan Basepair',loan_date=None) @@ -129,7 +148,8 @@ book_id to 1 in all loans whose book_id is 2:: >>> db.loans.update(db.loans.book_id==2, book_id=1) >>> db.loans.filter_by(book_id=1).all() - [MappedLoans(book_id=1,user_name=u'Joe Student',loan_date=datetime.datetime(2006, 7, 12, 0, 0))] + [MappedLoans(book_id=1,user_name=u'Joe Student', + loan_date=datetime.datetime(2006, 7, 12, 0, 0))] Joins @@ -140,13 +160,15 @@ tables all at once. In this situation, it is far more efficient to have the database perform the necessary join. (Here we do not have *a lot of data* but hopefully the concept is still clear.) SQLAlchemy is smart enough to recognize that loans has a foreign key to users, and -uses that as the join condition automatically. - -:: +uses that as the join condition automatically:: >>> join1 = db.join(db.users, db.loans, isouter=True) >>> join1.filter_by(name='Joe Student').all() - [MappedJoin(name=u'Joe Student',email=u'student@example.edu',password=u'student',classname=None,admin=0,book_id=1,user_name=u'Joe Student',loan_date=datetime.datetime(2006, 7, 12, 0, 0))] + [ + MappedJoin(name=u'Joe Student',email=u'student@example.edu', + password=u'student',classname=None,admin=0,book_id=1, + user_name=u'Joe Student',loan_date=datetime.datetime(2006, 7, 12, 0, 0)) + ] If you're unfortunate enough to be using MySQL with the default MyISAM storage engine, you'll have to specify the join condition manually, @@ -162,20 +184,29 @@ books table:: >>> join2 = db.join(join1, db.books) >>> join2.all() - [MappedJoin(name=u'Joe Student',email=u'student@example.edu',password=u'student',classname=None,admin=0,book_id=1,user_name=u'Joe Student',loan_date=datetime.datetime(2006, 7, 12, 0, 0),id=1,title=u'Mustards I Have Known',published_year=u'1989',authors=u'Jones')] + [ + MappedJoin(name=u'Joe Student',email=u'student@example.edu', + password=u'student',classname=None,admin=0,book_id=1, + user_name=u'Joe Student',loan_date=datetime.datetime(2006, 7, 12, 0, 0), + id=1,title=u'Mustards I Have Known',published_year=u'1989', + authors=u'Jones') + ] If you join tables that have an identical column name, wrap your join with `with_labels`, to disambiguate columns with their table name (.c is short for .columns):: >>> db.with_labels(join1).c.keys() - [u'users_name', u'users_email', u'users_password', u'users_classname', u'users_admin', u'loans_book_id', u'loans_user_name', u'loans_loan_date'] + [u'users_name', u'users_email', u'users_password', + u'users_classname', u'users_admin', u'loans_book_id', + u'loans_user_name', u'loans_loan_date'] You can also join directly to a labeled object:: >>> labeled_loans = db.with_labels(db.loans) >>> db.join(db.users, labeled_loans, isouter=True).c.keys() - [u'name', u'email', u'password', u'classname', u'admin', u'loans_book_id', u'loans_user_name', u'loans_loan_date'] + [u'name', u'email', u'password', u'classname', + u'admin', u'loans_book_id', u'loans_user_name', u'loans_loan_date'] Relationships @@ -188,13 +219,16 @@ You can define relationships on SqlSoup classes: These can then be used like a normal SA property: >>> db.users.get('Joe Student').loans - [MappedLoans(book_id=1,user_name=u'Joe Student',loan_date=datetime.datetime(2006, 7, 12, 0, 0))] + [MappedLoans(book_id=1,user_name=u'Joe Student', + loan_date=datetime.datetime(2006, 7, 12, 0, 0))] >>> db.users.filter(~db.users.loans.any()).all() - [MappedUsers(name=u'Bhargan Basepair',email='basepair+nospam@example.edu',password=u'basepair',classname=None,admin=1)] - + [MappedUsers(name=u'Bhargan Basepair', + email='basepair+nospam@example.edu', + password=u'basepair',classname=None,admin=1)] -relate can take any options that the relationship function accepts in normal mapper definition: +relate can take any options that the relationship function +accepts in normal mapper definition: >>> del db._cache['users'] >>> db.users.relate('loans', db.loans, order_by=db.loans.loan_date, cascade='all, delete-orphan') @@ -205,38 +239,47 @@ Advanced Use Sessions, Transations and Application Integration ------------------------------------------------- -**Note:** please read and understand this section thoroughly before using SqlSoup in any web application. +**Note:** please read and understand this section thoroughly +before using SqlSoup in any web application. -SqlSoup uses a ScopedSession to provide thread-local sessions. You -can get a reference to the current one like this:: +SqlSoup uses a ScopedSession to provide thread-local sessions. +You can get a reference to the current one like this:: >>> session = db.session -The default session is available at the module level in SQLSoup, via:: +The default session is available at the module level in SQLSoup, +via:: >>> from sqlalchemy.ext.sqlsoup import Session -The configuration of this session is ``autoflush=True``, ``autocommit=False``. -This means when you work with the SqlSoup object, you need to call ``db.commit()`` -in order to have changes persisted. You may also call ``db.rollback()`` to -roll things back. - -Since the SqlSoup object's Session automatically enters into a transaction as soon -as it's used, it is *essential* that you call ``commit()`` or ``rollback()`` -on it when the work within a thread completes. This means all the guidelines -for web application integration at :ref:`session_lifespan` must be followed. - -The SqlSoup object can have any session or scoped session configured onto it. -This is of key importance when integrating with existing code or frameworks -such as Pylons. If your application already has a ``Session`` configured, -pass it to your SqlSoup object:: +The configuration of this session is ``autoflush=True``, +``autocommit=False``. This means when you work with the SqlSoup +object, you need to call ``db.commit()`` in order to have +changes persisted. You may also call ``db.rollback()`` to roll +things back. + +Since the SqlSoup object's Session automatically enters into a +transaction as soon as it's used, it is *essential* that you +call ``commit()`` or ``rollback()`` on it when the work within a +thread completes. This means all the guidelines for web +application integration at :ref:`session_lifespan` must be +followed. + +The SqlSoup object can have any session or scoped session +configured onto it. This is of key importance when integrating +with existing code or frameworks such as Pylons. If your +application already has a ``Session`` configured, pass it to +your SqlSoup object:: >>> from myapplication import Session >>> db = SqlSoup(session=Session) -If the ``Session`` is configured with ``autocommit=True``, use ``flush()`` -instead of ``commit()`` to persist changes - in this case, the ``Session`` -closes out its transaction immediately and no external management is needed. ``rollback()`` is also not available. Configuring a new SQLSoup object in "autocommit" mode looks like:: +If the ``Session`` is configured with ``autocommit=True``, use +``flush()`` instead of ``commit()`` to persist changes - in this +case, the ``Session`` closes out its transaction immediately and +no external management is needed. ``rollback()`` is also not +available. Configuring a new SQLSoup object in "autocommit" mode +looks like:: >>> from sqlalchemy.orm import scoped_session, sessionmaker >>> db = SqlSoup('sqlite://', session=scoped_session(sessionmaker(autoflush=False, expire_on_commit=False, autocommit=True))) @@ -245,15 +288,13 @@ closes out its transaction immediately and no external management is needed. `` Mapping arbitrary Selectables ----------------------------- -SqlSoup can map any SQLAlchemy ``Selectable`` with the map -method. Let's map a ``Select`` object that uses an aggregate function; -we'll use the SQLAlchemy ``Table`` that SqlSoup introspected as the -basis. (Since we're not mapping to a simple table or join, we need to -tell SQLAlchemy how to find the *primary key* which just needs to be -unique within the select, and not necessarily correspond to a *real* -PK in the database.) - -:: +SqlSoup can map any SQLAlchemy :class:`.Selectable` with the map +method. Let's map an :func:`.expression.select` object that uses an aggregate +function; we'll use the SQLAlchemy :class:`.Table` that SqlSoup +introspected as the basis. (Since we're not mapping to a simple +table or join, we need to tell SQLAlchemy how to find the +*primary key* which just needs to be unique within the select, +and not necessarily correspond to a *real* PK in the database.):: >>> from sqlalchemy import select, func >>> b = db.books._table @@ -276,44 +317,45 @@ your db object:: Python is flexible like that! - Raw SQL ------- -SqlSoup works fine with SQLAlchemy's text construct, described in :ref:`sqlexpression_text`. -You can also execute textual SQL directly using the `execute()` method, -which corresponds to the `execute()` method on the underlying `Session`. -Expressions here are expressed like ``text()`` constructs, using named parameters +SqlSoup works fine with SQLAlchemy's text construct, described +in :ref:`sqlexpression_text`. You can also execute textual SQL +directly using the `execute()` method, which corresponds to the +`execute()` method on the underlying `Session`. Expressions here +are expressed like ``text()`` constructs, using named parameters with colons:: >>> rp = db.execute('select name, email from users where name like :name order by name', name='%Bhargan%') >>> for name, email in rp.fetchall(): print name, email Bhargan Basepair basepair+nospam@example.edu -Or you can get at the current transaction's connection using `connection()`. This is the -raw connection object which can accept any sort of SQL expression or raw SQL string passed to the database:: +Or you can get at the current transaction's connection using +`connection()`. This is the raw connection object which can +accept any sort of SQL expression or raw SQL string passed to +the database:: >>> conn = db.connection() >>> conn.execute("'select name, email from users where name like ? order by name'", '%Bhargan%') - Dynamic table names ------------------- -You can load a table whose name is specified at runtime with the entity() method: +You can load a table whose name is specified at runtime with the +entity() method: >>> tablename = 'loans' >>> db.entity(tablename) == db.loans True -entity() also takes an optional schema argument. If none is specified, the -default schema is used. - +entity() also takes an optional schema argument. If none is +specified, the default schema is used. """ from sqlalchemy import Table, MetaData, join -from sqlalchemy import schema, sql +from sqlalchemy import schema, sql, util from sqlalchemy.engine.base import Engine from sqlalchemy.orm import scoped_session, sessionmaker, mapper, \ class_mapper, relationship, session,\ @@ -403,7 +445,7 @@ def _selectable_name(selectable): x = x[1:] return x -def _class_for_table(session, engine, selectable, base_cls=object, **mapper_kwargs): +def _class_for_table(session, engine, selectable, base_cls, mapper_kwargs): selectable = expression._clause_element_as_expr(selectable) mapname = 'Mapped' + _selectable_name(selectable) # Py2K @@ -459,16 +501,25 @@ def _class_for_table(session, engine, selectable, base_cls=object, **mapper_kwar return klass class SqlSoup(object): - def __init__(self, engine_or_metadata, base=object, **kw): - """Initialize a new ``SqlSoup``. - - `base` is the class that all created entity classes should subclass. + """Represent an ORM-wrapped database resource.""" + + def __init__(self, engine_or_metadata, base=object, session=None): + """Initialize a new :class:`.SqlSoup`. + + :param engine_or_metadata: a string database URL, :class:`.Engine` + or :class:`.MetaData` object to associate with. If the + argument is a :class:`.MetaData`, it should be *bound* + to an :class:`.Engine`. + :param base: a class which will serve as the default class for + returned mapped classes. Defaults to ``object``. + :param session: a :class:`.ScopedSession` or :class:`.Session` with + which to associate ORM operations for this :class:`.SqlSoup` instance. + If ``None``, a :class:`.ScopedSession` that's local to this + module is used. - `args` may either be an ``SQLEngine`` or a set of arguments - suitable for passing to ``create_engine``. """ - self.session = kw.pop('session', Session) + self.session = session or Session self.base=base if isinstance(engine_or_metadata, MetaData): @@ -476,21 +527,32 @@ class SqlSoup(object): elif isinstance(engine_or_metadata, (basestring, Engine)): self._metadata = MetaData(engine_or_metadata) else: - raise ArgumentError("invalid engine or metadata argument %r" % engine_or_metadata) + raise ArgumentError("invalid engine or metadata argument %r" % + engine_or_metadata) self._cache = {} self.schema = None @property - def engine(self): + def bind(self): + """The :class:`.Engine` associated with this :class:`.SqlSoup`.""" return self._metadata.bind - bind = engine + engine = bind - def delete(self, *args, **kwargs): - self.session.delete(*args, **kwargs) + def delete(self, instance): + """Mark an instance as deleted.""" + + self.session.delete(instance) def execute(self, stmt, **params): + """Execute a SQL statement. + + The statement may be a string SQL string, + an :func:`.expression.select` construct, or an :func:`.expression.text` + construct. + + """ return self.session.execute(sql.text(stmt, bind=self.bind), **params) @property @@ -501,58 +563,222 @@ class SqlSoup(object): return self.session() def connection(self): + """Return the current :class:`.Connection` in use by the current transaction.""" + return self._underlying_session._connection_for_bind(self.bind) def flush(self): + """Flush pending changes to the database. + + See :meth:`.Session.flush`. + + """ self.session.flush() def rollback(self): + """Rollback the current transction. + + See :meth:`.Session.rollback`. + + """ self.session.rollback() def commit(self): + """Commit the current transaction. + + See :meth:`.Session.commit`. + + """ self.session.commit() def clear(self): + """Synonym for :meth:`.SqlSoup.expunge_all`.""" + self.session.expunge_all() - def expunge(self, *args, **kw): - self.session.expunge(*args, **kw) + def expunge(self, instance): + """Remove an instance from the :class:`.Session`. + + See :meth:`.Session.expunge`. + + """ + self.session.expunge(instance) def expunge_all(self): + """Clear all objects from the current :class:`.Session`. + + See :meth:`.Session.expunge_all`. + + """ self.session.expunge_all() - def map(self, selectable, **kwargs): - try: - t = self._cache[selectable] - except KeyError: - t = _class_for_table(self.session, self.engine, selectable, **kwargs) - self._cache[selectable] = t - return t + def map_to(self, attrname, tablename=None, selectable=None, + schema=None, base=None, mapper_args=util.frozendict()): + """Configure a mapping to the given attrname. + + This is the "master" method that can be used to create any + configuration. + + :param attrname: String attribute name which will be + established as an attribute on this :class:.`.SqlSoup` + instance. + :param base: a Python class which will be used as the + base for the mapped class. If ``None``, the "base" + argument specified by this :class:`.SqlSoup` + instance's constructor will be used, which defaults to + ``object``. + :param mapper_args: Dictionary of arguments which will + be passed directly to :func:`.orm.mapper`. + :param tablename: String name of a :class:`.Table` to be + reflected. If a :class:`.Table` is already available, + use the ``selectable`` argument. This argument is + mutually exclusive versus the ``selectable`` argument. + :param selectable: a :class:`.Table`, :class:`.Join`, or + :class:`.Select` object which will be mapped. This + argument is mutually exclusive versus the ``tablename`` + argument. + :param schema: String schema name to use if the + ``tablename`` argument is present. + + + """ + if attrname in self._cache: + raise InvalidRequestError( + "Attribute '%s' is already mapped to '%s'" % ( + attrname, + class_mapper(self._cache[attrname]).mapped_table + )) + + if tablename is not None: + if not isinstance(tablename, basestring): + raise ArgumentError("'tablename' argument must be a string." + ) + if selectable is not None: + raise ArgumentError("'tablename' and 'selectable' " + "arguments are mutually exclusive") + + selectable = Table(tablename, + self._metadata, + autoload=True, + autoload_with=self.bind, + schema=schema or self.schema) + elif schema: + raise ArgumentError("'tablename' argument is required when " + "using 'schema'.") + elif selectable is not None: + if not isinstance(selectable, expression.FromClause): + raise ArgumentError("'selectable' argument must be a " + "table, select, join, or other " + "selectable construct.") + else: + raise ArgumentError("'tablename' or 'selectable' argument is " + "required.") + + if not selectable.primary_key.columns: + if tablename: + raise PKNotFoundError( + "table '%s' does not have a primary " + "key defined" % tablename) + else: + raise PKNotFoundError( + "selectable '%s' does not have a primary " + "key defined" % selectable) + + mapped_cls = _class_for_table( + self.session, + self.engine, + selectable, + base or self.base, + mapper_args + ) + self._cache[attrname] = mapped_cls + return mapped_cls + + + def map(self, selectable, base=None, **mapper_args): + """Map a selectable directly. + + The class and its mapping are not cached and will + be discarded once dereferenced (as of 0.6.6). + + :param selectable: an :func:`.expression.select` construct. + :param base: a Python class which will be used as the + base for the mapped class. If ``None``, the "base" + argument specified by this :class:`.SqlSoup` + instance's constructor will be used, which defaults to + ``object``. + :param mapper_args: Dictionary of arguments which will + be passed directly to :func:`.orm.mapper`. + + """ - def with_labels(self, item): + return _class_for_table( + self.session, + self.engine, + selectable, + base or self.base, + mapper_args + ) + + def with_labels(self, selectable, base=None, **mapper_args): + """Map a selectable directly, wrapping the + selectable in a subquery with labels. + + The class and its mapping are not cached and will + be discarded once dereferenced (as of 0.6.6). + + :param selectable: an :func:`.expression.select` construct. + :param base: a Python class which will be used as the + base for the mapped class. If ``None``, the "base" + argument specified by this :class:`.SqlSoup` + instance's constructor will be used, which defaults to + ``object``. + :param mapper_args: Dictionary of arguments which will + be passed directly to :func:`.orm.mapper`. + + """ + # TODO give meaningful aliases return self.map( - expression._clause_element_as_expr(item). + expression._clause_element_as_expr(selectable). select(use_labels=True). - alias('foo')) + alias('foo'), base=base, **mapper_args) - def join(self, *args, **kwargs): - j = join(*args, **kwargs) - return self.map(j) + def join(self, left, right, onclause=None, isouter=False, + base=None, **mapper_args): + """Create an :func:`.expression.join` and map to it. + + The class and its mapping are not cached and will + be discarded once dereferenced (as of 0.6.6). + + :param left: a mapped class or table object. + :param right: a mapped class or table object. + :param onclause: optional "ON" clause construct.. + :param isouter: if True, the join will be an OUTER join. + :param base: a Python class which will be used as the + base for the mapped class. If ``None``, the "base" + argument specified by this :class:`.SqlSoup` + instance's constructor will be used, which defaults to + ``object``. + :param mapper_args: Dictionary of arguments which will + be passed directly to :func:`.orm.mapper`. + + """ + + j = join(left, right, onclause=onclause, isouter=isouter) + return self.map(j, base=base, **mapper_args) def entity(self, attr, schema=None): + """Return the named entity from this :class:`.SqlSoup`, or + create if not present. + + For more generalized mapping, see :meth:`.map_to`. + + """ try: - t = self._cache[attr] + return self._cache[attr] except KeyError, ke: - table = Table(attr, self._metadata, autoload=True, autoload_with=self.bind, schema=schema or self.schema) - if not table.primary_key.columns: - raise PKNotFoundError('table %r does not have a primary key defined [columns: %s]' % (attr, ','.join(table.c.keys()))) - if table.columns: - t = _class_for_table(self.session, self.engine, table, self.base) - else: - t = None - self._cache[attr] = t - return t + return self.map_to(attr, tablename=attr, schema=schema) def __getattr__(self, attr): return self.entity(attr) diff --git a/test/ext/test_sqlsoup.py b/test/ext/test_sqlsoup.py index 7fe8ab1782..0767d6c7aa 100644 --- a/test/ext/test_sqlsoup.py +++ b/test/ext/test_sqlsoup.py @@ -1,7 +1,8 @@ from sqlalchemy.ext import sqlsoup -from sqlalchemy.test.testing import TestBase, eq_, assert_raises +from sqlalchemy.test.testing import TestBase, eq_, assert_raises, \ + assert_raises_message from sqlalchemy import create_engine, or_, desc, select, func, exc, \ - Table, util + Table, util, Column, Integer from sqlalchemy.orm import scoped_session, sessionmaker import datetime @@ -30,6 +31,76 @@ class SQLSoupTest(TestBase): for sql in _teardown: engine.execute(sql) + def test_map_to_attr_present(self): + db = sqlsoup.SqlSoup(engine) + + users = db.users + assert_raises_message( + exc.InvalidRequestError, + "Attribute 'users' is already mapped", + db.map_to, 'users', tablename='users' + ) + + def test_map_to_table_not_string(self): + db = sqlsoup.SqlSoup(engine) + + table = Table('users', db._metadata, Column('id', Integer, primary_key=True)) + assert_raises_message( + exc.ArgumentError, + "'tablename' argument must be a string.", + db.map_to, 'users', tablename=table + ) + + def test_map_to_table_or_selectable(self): + db = sqlsoup.SqlSoup(engine) + + table = Table('users', db._metadata, Column('id', Integer, primary_key=True)) + assert_raises_message( + exc.ArgumentError, + "'tablename' and 'selectable' arguments are mutually exclusive", + db.map_to, 'users', tablename='users', selectable=table + ) + + def test_map_to_no_pk_selectable(self): + db = sqlsoup.SqlSoup(engine) + + table = Table('users', db._metadata, Column('id', Integer)) + assert_raises_message( + sqlsoup.PKNotFoundError, + "table 'users' does not have a primary ", + db.map_to, 'users', selectable=table + ) + def test_map_to_invalid_schema(self): + db = sqlsoup.SqlSoup(engine) + + table = Table('users', db._metadata, Column('id', Integer)) + assert_raises_message( + exc.ArgumentError, + "'tablename' argument is required when " + "using 'schema'.", + db.map_to, 'users', selectable=table, schema='hoho' + ) + def test_map_to_nothing(self): + db = sqlsoup.SqlSoup(engine) + + assert_raises_message( + exc.ArgumentError, + "'tablename' or 'selectable' argument is " + "required.", + db.map_to, 'users', + ) + + def test_map_to_string_not_selectable(self): + db = sqlsoup.SqlSoup(engine) + + assert_raises_message( + exc.ArgumentError, + "'selectable' argument must be a " + "table, select, join, or other " + "selectable construct.", + db.map_to, 'users', selectable='users' + ) + def test_bad_names(self): db = sqlsoup.SqlSoup(engine) @@ -278,7 +349,7 @@ class SQLSoupTest(TestBase): email=u'student@example.edu', password=u'student', classname=None, admin=0)]) - def test_no_pk(self): + def test_no_pk_reflected(self): db = sqlsoup.SqlSoup(engine) assert_raises(sqlsoup.PKNotFoundError, getattr, db, 'nopk') diff --git a/test/perf/insertspeed.py b/test/perf/insertspeed.py index 0491e9f959..7d2712837a 100644 --- a/test/perf/insertspeed.py +++ b/test/perf/insertspeed.py @@ -1,4 +1,3 @@ -import testenv; testenv.simple_setup() import sys, time from sqlalchemy import * from sqlalchemy.orm import * @@ -87,7 +86,7 @@ def all(): run_profiled(sa_profiled_insert_many, 'SQLAlchemy bulk insert/select, profiled', - 1000) + 50000) print "\nIndividual INSERTS via execute():\n" @@ -101,7 +100,7 @@ def all(): run_profiled(sa_profiled_insert, 'SQLAlchemy individual insert/select, profiled', - 1000) + 50000) finally: metadata.drop_all()