diff --git a/CHANGELOG.md b/CHANGELOG.md index 30702ccee..fb85ad579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ releases, visit GitHub: https://github.com/coleifer/peewee/releases +## 3.3.2 + +* Add methods for `union()`, `union_all`, `intersect()` and `except_()`. + Previously, these methods were only available as operator overloads. +* Removed some Python 2.6-specific support code, as 2.6 is no longer officially + supported. +* Fixed model-graph resolution logic for deferred foreign-keys. +* Better support for UPDATE...FROM queries (Postgresql). + +[View commits](https://github.com/coleifer/peewee/compare/3.3.1...3.3.2) + ## 3.3.1 * Fixed long-standing bug in 3.x regarding using column aliases with queries diff --git a/docs/peewee/api.rst b/docs/peewee/api.rst index deabd3ae0..fec110910 100644 --- a/docs/peewee/api.rst +++ b/docs/peewee/api.rst @@ -110,6 +110,8 @@ Database .. py:method:: cursor([commit=None]) + :param commit: For internal use. + Return a ``cursor`` object on the current connection. If a connection is not open, one will be opened. The cursor will be whatever the underlying database-driver uses to encapsulate a database cursor. @@ -1688,18 +1690,35 @@ Query-builder Select query helper-class that implements operator-overloads for creating compound queries. + .. py:method:: union_all(dest) + + Create a UNION ALL query with ``dest``. + .. py:method:: __add__(dest) Create a UNION ALL query with ``dest``. + .. py:method:: union(dest) + + Create a UNION query with ``dest``. + .. py:method:: __or__(dest) Create a UNION query with ``dest``. + .. py:method:: intersect(dest) + + Create an INTERSECT query with ``dest``. + .. py:method:: __and__(dest) Create an INTERSECT query with ``dest``. + .. py:method:: except_(dest) + + Create an EXCEPT query with ``dest``. Note that the method name has a + trailing "_" character since ``except`` is a Python reserved word. + .. py:method:: __sub__(dest) Create an EXCEPT query with ``dest``. diff --git a/docs/peewee/changes.rst b/docs/peewee/changes.rst index 1797ea690..472190557 100644 --- a/docs/peewee/changes.rst +++ b/docs/peewee/changes.rst @@ -24,6 +24,8 @@ Database well as :py:meth:`Model.create_table` and :py:meth:`Model.drop_table` all default to ``safe=True`` (``create_table`` will create if not exists, ``drop_table`` will drop if exists). * ``connect_kwargs`` attribute has been renamed to ``connect_params`` +* initialization parameter for custom field-type definitions has changed + from ``fields`` to ``field_types``. Model Meta options ^^^^^^^^^^^^^^^^^^ @@ -89,6 +91,9 @@ When using :py:func:`prefetch`, the collected instances will be stored in the same attribute as the foreign-key's ``backref``. Previously, you would access joined instances using ``(backref)_prefetch``. +The :py:class:`SQL` object, used to create a composable a SQL string, now +expects the second parameter to be a list/tuple of parameters. + Removed Extensions ^^^^^^^^^^^^^^^^^^ diff --git a/peewee.py b/peewee.py index 6a702f89f..4fdaff7f4 100644 --- a/peewee.py +++ b/peewee.py @@ -57,7 +57,7 @@ mysql = None -__version__ = '3.3.1' +__version__ = '3.3.2' __all__ = [ 'AsIs', 'AutoField', @@ -343,17 +343,7 @@ def merge_dict(source, overrides): merged.update(overrides) return merged -if sys.version_info[:2] == (2, 6): - import types - def is_model(obj): - if isinstance(obj, (type, types.ClassType)): - return issubclass(obj, Model) - return False -else: - def is_model(obj): - if isclass(obj): - return issubclass(obj, Model) - return False +is_model = lambda o: isclass(o) and issubclass(o, Model) def ensure_tuple(value): if value is not None: @@ -1725,10 +1715,10 @@ def method(self, other): class SelectQuery(Query): - __add__ = __compound_select__('UNION ALL') - __or__ = __compound_select__('UNION') - __and__ = __compound_select__('INTERSECT') - __sub__ = __compound_select__('EXCEPT') + union_all = __add__ = __compound_select__('UNION ALL') + union = __or__ = __compound_select__('UNION') + intersect = __and__ = __compound_select__('INTERSECT') + except_ = __sub__ = __compound_select__('EXCEPT') __radd__ = __compound_select__('UNION ALL', inverted=True) __ror__ = __compound_select__('UNION', inverted=True) __rand__ = __compound_select__('INTERSECT', inverted=True) @@ -2066,7 +2056,8 @@ def __sql__(self, ctx): .sql(CommaNodeList(expressions))) if self._from: - ctx.literal(' FROM ').sql(CommaNodeList(self._from)) + with ctx.scope_source(parentheses=False): + ctx.literal(' FROM ').sql(CommaNodeList(self._from)) if self._where: ctx.literal(' WHERE ').sql(self._where) @@ -5552,8 +5543,11 @@ def sort_models(models): def dfs(model): if model in models and model not in seen: seen.add(model) - for rel_model in model._meta.refs.values(): - dfs(rel_model) + for foreign_key, rel_model in model._meta.refs.items(): + # Do not depth-first search deferred foreign-keys as this can + # cause tables to be created in the incorrect order. + if not foreign_key.deferred: + dfs(rel_model) if model._meta.depends_on: for dependency in model._meta.depends_on: dfs(dependency) diff --git a/playhouse/postgres_ext.py b/playhouse/postgres_ext.py index 31dfe1d35..89c998ab2 100644 --- a/playhouse/postgres_ext.py +++ b/playhouse/postgres_ext.py @@ -428,7 +428,7 @@ def _connect(self): register_hstore(conn, globally=True) return conn - def cursor(self, commit): + def cursor(self, commit=None): if self.is_closed(): self.connect() if commit is __named_cursor__: diff --git a/playhouse/sqlite_ext.py b/playhouse/sqlite_ext.py index 6f1a6d48d..89aa8f319 100644 --- a/playhouse/sqlite_ext.py +++ b/playhouse/sqlite_ext.py @@ -213,6 +213,10 @@ def _create_virtual_table(self, safe=True, **options): options = self.model.clean_options( merge_dict(self.model._meta.options, options)) + # Structure: + # CREATE VIRTUAL TABLE + # USING + # ([prefix_arguments, ...] fields, ... [arguments, ...], [options...]) ctx = self._create_context() ctx.literal('CREATE VIRTUAL TABLE ') if safe: diff --git a/tests/model_sql.py b/tests/model_sql.py index fbb1ad5ca..d02494869 100644 --- a/tests/model_sql.py +++ b/tests/model_sql.py @@ -368,7 +368,7 @@ class Account(TestModel): 'UPDATE "account" SET ' '"contact_first" = "first", ' '"contact_last" = "last" ' - 'FROM "salesperson" ' + 'FROM "salesperson" AS "t1" ' 'WHERE ("sales_id" = "id")'), []) def test_delete(self): diff --git a/tests/models.py b/tests/models.py index be2ea34aa..aa6d54143 100644 --- a/tests/models.py +++ b/tests/models.py @@ -4,6 +4,8 @@ import unittest from peewee import * +from peewee import QualifiedNames +from peewee import sort_models from .base import db from .base import get_in_memory_db @@ -835,6 +837,14 @@ class Foo(TestModel): 'SELECT "t1"."id", "t1"."foo_id" FROM "note" AS "t1" ' 'WHERE ("t1"."foo_id" = ?)'), [1337]) + def test_deferred_fk_dependency_graph(self): + class AUser(TestModel): + foo = DeferredForeignKey('Tweet') + class ZTweet(TestModel): + user = ForeignKeyField(AUser, backref='ztweets') + + self.assertEqual(sort_models([AUser, ZTweet]), [AUser, ZTweet]) + def test_table_schema(self): class Schema(TestModel): pass @@ -2982,3 +2992,64 @@ def test_sequence(self): self.assertEqual(s1.seq_id, 1) self.assertEqual(s2.seq_id, 2) self.assertEqual(s3.seq_id, 3) + + +@skip_case_unless(IS_POSTGRESQL) +class TestUpdateFrom(ModelTestCase): + requires = [User] + + def test_update_from(self): + u1, u2 = [User.create(username=username) for username in ('u1', 'u2')] + data = [(u1.id, 'u1-x'), (u2.id, 'u2-x')] + vl = ValuesList(data, columns=('id', 'username'), alias='tmp') + query = (User + .update({User.username: QualifiedNames(vl.c.username)}) + .from_(vl) + .where(QualifiedNames(User.id == vl.c.id))) + self.assertSQL(query, ( + 'UPDATE "users" SET "username" = "tmp"."username" ' + 'FROM (VALUES (?, ?), (?, ?)) AS "tmp"("id", "username") ' + 'WHERE ("users"."id" = "tmp"."id")'), + [u1.id, 'u1-x', u2.id, 'u2-x']) + + query.execute() + usernames = [u.username for u in User.select().order_by(User.username)] + self.assertEqual(usernames, ['u1-x', 'u2-x']) + + def test_update_from_subselect(self): + u1, u2 = [User.create(username=username) for username in ('u1', 'u2')] + data = [(u1.id, 'u1-y'), (u2.id, 'u2-y')] + vl = ValuesList(data, columns=('id', 'username'), alias='tmp') + subq = vl.select(vl.c.id, vl.c.username) + query = (User + .update({User.username: QualifiedNames(subq.c.username)}) + .from_(subq) + .where(QualifiedNames(User.id == subq.c.id))) + self.assertSQL(query, ( + 'UPDATE "users" SET "username" = "t1"."username" FROM (' + 'SELECT "tmp"."id", "tmp"."username" ' + 'FROM (VALUES (?, ?), (?, ?)) AS "tmp"("id", "username")) AS "t1" ' + 'WHERE ("users"."id" = "t1"."id")'), + [u1.id, 'u1-y', u2.id, 'u2-y']) + + query.execute() + usernames = [u.username for u in User.select().order_by(User.username)] + self.assertEqual(usernames, ['u1-y', 'u2-y']) + + @requires_models(User, Tweet) + def test_update_from_simple(self): + u = User.create(username='u1') + t1 = Tweet.create(user=u, content='t1') + t2 = Tweet.create(user=u, content='t2') + + query = (User + .update({User.username: QualifiedNames(Tweet.content)}) + .from_(Tweet) + .where(Tweet.content == 't2')) + self.assertSQL(query, ( + 'UPDATE "users" SET "username" = "t1"."content" ' + 'FROM "tweet" AS "t1" ' + 'WHERE ("content" = ?)'), ['t2']) + query.execute() + + self.assertEqual(User.get(User.id == u.id).username, 't2') diff --git a/tests/sql.py b/tests/sql.py index 03b2a815b..d4d232f23 100644 --- a/tests/sql.py +++ b/tests/sql.py @@ -321,6 +321,34 @@ def test_compound_select(self): 'FROM "users" AS "U2" ' 'WHERE ("U2"."superuser" = ?)'), ['charlie', True, False]) + def test_compound_operations(self): + admin = (User + .select(User.c.username, Value('admin').alias('role')) + .where(User.c.is_admin == True)) + editors = (User + .select(User.c.username, Value('editor').alias('role')) + .where(User.c.is_editor == True)) + + union = admin.union(editors) + self.assertSQL(union, ( + 'SELECT "t1"."username", ? AS "role" ' + 'FROM "users" AS "t1" ' + 'WHERE ("t1"."is_admin" = ?) ' + 'UNION ' + 'SELECT "t2"."username", ? AS "role" ' + 'FROM "users" AS "t2" ' + 'WHERE ("t2"."is_editor" = ?)'), ['admin', 1, 'editor', 1]) + + xcept = editors.except_(admin) + self.assertSQL(xcept, ( + 'SELECT "t1"."username", ? AS "role" ' + 'FROM "users" AS "t1" ' + 'WHERE ("t1"."is_editor" = ?) ' + 'EXCEPT ' + 'SELECT "t2"."username", ? AS "role" ' + 'FROM "users" AS "t2" ' + 'WHERE ("t2"."is_admin" = ?)'), ['editor', 1, 'admin', 1]) + def test_join_on_query(self): inner = User.select(User.c.id).alias('j1') query = (Tweet