From 43a8c813afeddbfc4ff89440290bc38433376931 Mon Sep 17 00:00:00 2001 From: Jeffrey Slort Date: Wed, 4 Oct 2017 21:23:27 +0200 Subject: [PATCH 01/15] Fixes #554 representation of new model instances (#555) Fixed representation of new model instances Added "transient" id for unpersisted models --- flask_sqlalchemy/model.py | 6 +++++- tests/test_model_class.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/flask_sqlalchemy/model.py b/flask_sqlalchemy/model.py index 5e5ae797..43f76b95 100644 --- a/flask_sqlalchemy/model.py +++ b/flask_sqlalchemy/model.py @@ -118,5 +118,9 @@ class Model(object): query = None def __repr__(self): - pk = ', '.join(to_str(value) for value in inspect(self).identity) + identity = inspect(self).identity + if identity is None: + pk = "(transient {0})".format(id(self)) + else: + pk = ', '.join(to_str(value) for value in identity) return '<{0} {1}>'.format(type(self).__name__, pk) diff --git a/tests/test_model_class.py b/tests/test_model_class.py index c64d8ad3..4e381bcc 100644 --- a/tests/test_model_class.py +++ b/tests/test_model_class.py @@ -43,6 +43,7 @@ class Report(db.Model): db.create_all() u = User(name='test') + assert repr(u).startswith("' From 84ad4c2a05bec1cfdb2c6c546918c8032889d885 Mon Sep 17 00:00:00 2001 From: Grey Li Date: Tue, 19 Dec 2017 08:41:12 +0800 Subject: [PATCH 02/15] Docs: Fixed SQL syntax --- docs/models.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models.rst b/docs/models.rst index 5824be27..cbda698f 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -105,7 +105,7 @@ when SQLAlchemy will load the data from the database: - ``'select'`` / ``True`` (which is the default, but explicit is better than implicit) means that SQLAlchemy will load the data as necessary - in one go using a standard select statement. + in one go using a standard ``SELECT`` statement. - ``'joined'`` / ``False`` tells SQLAlchemy to load the relationship in the same query as the parent using a ``JOIN`` statement. - ``'subquery'`` works like ``'joined'`` but instead SQLAlchemy will From 690bedd06f29ad752ee6d154b8a971a6dba08f01 Mon Sep 17 00:00:00 2001 From: Alexandre Figura Date: Thu, 11 Jan 2018 17:10:01 +0100 Subject: [PATCH 03/15] Fix Alembic broken links in the doc --- docs/config.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 7a824eb6..af63c51e 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -130,8 +130,8 @@ specify a `custom constraint naming convention `_ in conjunction with SQLAlchemy 0.9.2 or higher. Doing so is important for dealing with database migrations (for instance using -`alembic `_ as stated -`here `_. Here's an +`alembic `_ as stated +`here `_. Here's an example, as suggested by the SQLAlchemy docs:: from sqlalchemy import MetaData From 7661e74a726b78fa80bc06a1a94a1efda3a5659c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 21 Jan 2018 22:49:29 +0200 Subject: [PATCH 04/15] Drop support for EOL Python 2.6 --- .travis.yml | 2 -- setup.py | 1 - tox.ini | 5 ++--- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index b8837410..c6365d14 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,6 @@ matrix: env: TOXENV=py,py-lowest,codecov - python: 2.7 env: TOXENV=py,codecov - - python: 2.6 - env: TOXENV=py,py-lowest,codecov - python: pypy env: TOXENV=py,codecov - python: nightly diff --git a/setup.py b/setup.py index 91c87d5a..545918d6 100755 --- a/setup.py +++ b/setup.py @@ -43,7 +43,6 @@ 'Topic :: Software Development :: Libraries :: Python Modules', 'Programming Language :: Python', 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', diff --git a/tox.ini b/tox.ini index e3a1ccc8..a34e0c2c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py{36,35,34,33,27,26,py} - py{36,33,27,26,py}-lowest + py{36,35,34,33,27,py} + py{36,33,27,py}-lowest docs_html coverage_report @@ -39,7 +39,6 @@ deps = codecov skip_install = true commands = - python -c 'import sys, pip; sys.version_info < (2, 7) and pip.main(["install", "argparse", "-q"])' coverage combine coverage report codecov From 488cf3578e2656e5fc1820504a7326da39e40bff Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 21 Jan 2018 23:00:02 +0200 Subject: [PATCH 05/15] Use automatic formatters --- docs/conf.py | 2 +- flask_sqlalchemy/model.py | 4 ++-- tests/test_pagination.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fae9cd73..1e805970 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = u'Flask-SQLAlchemy' -copyright = u'2010 - {0}, Armin Ronacher'.format(datetime.utcnow().year) +copyright = u'2010 - {}, Armin Ronacher'.format(datetime.utcnow().year) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/flask_sqlalchemy/model.py b/flask_sqlalchemy/model.py index 9c55db2a..d34e1f10 100644 --- a/flask_sqlalchemy/model.py +++ b/flask_sqlalchemy/model.py @@ -148,7 +148,7 @@ class Model(object): def __repr__(self): identity = inspect(self).identity if identity is None: - pk = "(transient {0})".format(id(self)) + pk = "(transient {})".format(id(self)) else: pk = ', '.join(to_str(value) for value in identity) - return '<{0} {1}>'.format(type(self).__name__, pk) + return '<{} {}>'.format(type(self).__name__, pk) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 05621c71..051b5efd 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -30,7 +30,7 @@ def test_query_paginate(app, db, Todo): @app.route('/') def index(): p = Todo.query.paginate() - return '{0} items retrieved'.format(len(p.items)) + return '{} items retrieved'.format(len(p.items)) c = app.test_client() # request default From 11f5cee3ca21d16ec2bcd2599d598dd8ab05abc5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 22 Jan 2018 10:06:07 +0200 Subject: [PATCH 06/15] Remove unused variable --- scripts/make-release.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/make-release.py b/scripts/make-release.py index f6a077a5..36301085 100755 --- a/scripts/make-release.py +++ b/scripts/make-release.py @@ -26,7 +26,6 @@ def parse_changelog(): match = re.search('^Version\s+(.*)', line.strip()) if match is None: continue - length = len(match.group(1)) version = match.group(1).strip() if lineiter.next().count('-') != len(match.group(0)): continue From 9889f77f2f9b5e750682901e48d14baa73a332fb Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 22 Jan 2018 10:08:45 +0200 Subject: [PATCH 07/15] Remove unused imports --- examples/hello.py | 2 +- flask_sqlalchemy/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/hello.py b/examples/hello.py index 4275ccd2..3ead2425 100644 --- a/examples/hello.py +++ b/examples/hello.py @@ -1,6 +1,6 @@ from datetime import datetime from flask import Flask, request, flash, url_for, redirect, \ - render_template, abort + render_template from flask_sqlalchemy import SQLAlchemy diff --git a/flask_sqlalchemy/__init__.py b/flask_sqlalchemy/__init__.py index 520afa00..cc82d5d7 100644 --- a/flask_sqlalchemy/__init__.py +++ b/flask_sqlalchemy/__init__.py @@ -29,7 +29,7 @@ from sqlalchemy.orm.session import Session as SessionBase from flask_sqlalchemy.model import Model -from ._compat import itervalues, string_types, to_str, xrange +from ._compat import itervalues, string_types, xrange from .model import DefaultMeta __version__ = '2.3.2' From 29883621a022bc4878d931ed737c50000b0f769e Mon Sep 17 00:00:00 2001 From: Alistair Lynn Date: Fri, 2 Feb 2018 20:40:30 +0000 Subject: [PATCH 08/15] Remove u'' prefixes --- docs/queries.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 50c5cb15..107b6084 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -82,7 +82,7 @@ Retrieve a user by username: >>> peter.id 2 >>> peter.email -u'peter@example.org' +'peter@example.org' Same as above but for a non existing username gives `None`: @@ -93,22 +93,22 @@ True Selecting a bunch of users by a more complex expression: >>> User.query.filter(User.email.endswith('@example.com')).all() -[, ] +[, ] Ordering users by something: >>> User.query.order_by(User.username).all() -[, , ] +[, , ] Limiting users: >>> User.query.limit(1).all() -[] +[] Getting user by primary key: >>> User.query.get(1) - + Queries in Views From 4620931027ad7c36a34beb313def0c7d7395b98e Mon Sep 17 00:00:00 2001 From: Alistair Lynn Date: Fri, 2 Feb 2018 20:45:32 +0000 Subject: [PATCH 09/15] Python3-ify quickstart.rst --- docs/quickstart.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index e29d5199..6eb36c26 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -60,9 +60,9 @@ But they are not yet in the database, so let's make sure they are:: Accessing the data in database is easy as a pie:: >>> User.query.all() - [, ] + [, ] >>> User.query.filter_by(username='admin').first() - + Note how we never defined a ``__init__`` method on the ``User`` class? That's because SQLAlchemy adds an implicit constructor to all model @@ -143,8 +143,8 @@ load all categories and their posts, you could do it like this:: >>> from sqlalchemy.orm import joinedload >>> query = Category.query.options(joinedload('posts')) >>> for category in query: - ... print category, category.posts - [, ] + ... print(category, category.posts) + [, ] If you want to get a query object for that relationship, you can do so From ef03ca1f1f6278a9792c6575b63820426c791724 Mon Sep 17 00:00:00 2001 From: Jan Runge Date: Wed, 21 Feb 2018 17:40:15 +0100 Subject: [PATCH 10/15] Fix model instantiation in documentation --- docs/queries.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/queries.rst b/docs/queries.rst index 50c5cb15..7081a5ef 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -26,7 +26,7 @@ It is essentially a beefed up version of a database transaction. This is how it works: >>> from yourapp import User ->>> me = User('admin', 'admin@example.com') +>>> me = User(username='admin', email='admin@example.com') >>> db.session.add(me) >>> db.session.commit() From fb6ee57512cc0d812f9f4f7b5c9e77f1708bad42 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 12 Mar 2018 12:50:10 +0200 Subject: [PATCH 11/15] Drop support for EOL Python 3.3 --- .travis.yml | 2 -- setup.py | 1 - tox.ini | 4 ++-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index c6365d14..9cc09e02 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,8 +9,6 @@ matrix: env: TOXENV=py,codecov - python: 3.4 env: TOXENV=py,codecov - - python: 3.3 - env: TOXENV=py,py-lowest,codecov - python: 2.7 env: TOXENV=py,codecov - python: pypy diff --git a/setup.py b/setup.py index 545918d6..a2774506 100755 --- a/setup.py +++ b/setup.py @@ -45,7 +45,6 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', diff --git a/tox.ini b/tox.ini index a34e0c2c..3fe51dfc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py{36,35,34,33,27,py} - py{36,33,27,py}-lowest + py{36,35,34,27,py} + py{36,34,27,py}-lowest docs_html coverage_report From 9a3a2df346ac97ae56403e78cf35b4edbf39409a Mon Sep 17 00:00:00 2001 From: trollefson Date: Fri, 30 Mar 2018 08:19:06 -0500 Subject: [PATCH 12/15] make measurement of pagination page length optional --- flask_sqlalchemy/__init__.py | 11 +++++++---- tests/test_pagination.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/flask_sqlalchemy/__init__.py b/flask_sqlalchemy/__init__.py index 520afa00..47be473f 100644 --- a/flask_sqlalchemy/__init__.py +++ b/flask_sqlalchemy/__init__.py @@ -325,7 +325,7 @@ def __init__(self, query, page, per_page, total, items): @property def pages(self): """The total number of pages""" - if self.per_page == 0: + if self.per_page == 0 or self.total is None: pages = 0 else: pages = int(ceil(self.total / float(self.per_page))) @@ -427,7 +427,7 @@ def first_or_404(self): abort(404) return rv - def paginate(self, page=None, per_page=None, error_out=True, max_per_page=None): + def paginate(self, page=None, per_page=None, error_out=True, max_per_page=None, count=True): """Returns ``per_page`` items from page ``page``. If ``page`` or ``per_page`` are ``None``, they will be retrieved from @@ -494,8 +494,11 @@ def paginate(self, page=None, per_page=None, error_out=True, max_per_page=None): abort(404) # No need to count if we're on the first page and there are fewer - # items than we expected. - if page == 1 and len(items) < per_page: + # items than we expected or if count is disabled. + + if not count: + total = None + elif page == 1 and len(items) < per_page: total = len(items) else: total = self.order_by(None).count() diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 05621c71..74dd4dd1 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -22,6 +22,11 @@ def test_pagination_pages_when_0_items_per_page(): assert p.pages == 0 +def test_pagination_pages_when_total_is_none(): + p = fsa.Pagination(None, 1, 100, None, []) + assert p.pages == 0 + + def test_query_paginate(app, db, Todo): with app.app_context(): db.session.add_all([Todo('', '') for _ in range(100)]) @@ -68,3 +73,11 @@ def test_paginate_min(app, db, Todo): with pytest.raises(NotFound): Todo.query.paginate(per_page=-1) + + +def test_paginate_without_count(app, db, Todo): + with app.app_context(): + db.session.add_all(Todo('', '') for _ in range(20)) + db.session.commit() + + assert len(Todo.query.paginate(count=False, page=1, per_page=10).items) == 10 From 3f25130b77bc9861bd1c50e14266bc4c195b8440 Mon Sep 17 00:00:00 2001 From: trollefson Date: Fri, 30 Mar 2018 09:46:38 -0500 Subject: [PATCH 13/15] docs for disabling page count --- flask_sqlalchemy/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_sqlalchemy/__init__.py b/flask_sqlalchemy/__init__.py index 47be473f..a2b55637 100644 --- a/flask_sqlalchemy/__init__.py +++ b/flask_sqlalchemy/__init__.py @@ -433,7 +433,8 @@ def paginate(self, page=None, per_page=None, error_out=True, max_per_page=None, If ``page`` or ``per_page`` are ``None``, they will be retrieved from the request query. If ``max_per_page`` is specified, ``per_page`` will be limited to that value. If there is no request or they aren't in the - query, they default to 1 and 20 respectively. + query, they default to 1 and 20 respectively. If ``count`` is ``False``, + no query to help determine total page count will be run. When ``error_out`` is ``True`` (default), the following rules will cause a 404 response: From 3aaca4ecee1e31aebf8632b07f58279ed8f374e2 Mon Sep 17 00:00:00 2001 From: Vik Date: Thu, 3 May 2018 16:26:25 +0100 Subject: [PATCH 14/15] fix typo in example added missing self argument to the __init__ def --- docs/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 6eb36c26..b4d7749a 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -73,7 +73,7 @@ constructor with those ``**kwargs`` to preserve this behavior:: class Foo(db.Model): # ... - def __init__(**kwargs): + def __init__(self, **kwargs): super(Foo, self).__init__(**kwargs) # do custom stuff From 57233f41132e5098c88c7b84a627735eaf6df34f Mon Sep 17 00:00:00 2001 From: Micha Moskovic Date: Mon, 15 Oct 2018 13:33:29 +0200 Subject: [PATCH 15/15] correctly handle signals in nested transactions closes #645 --- flask_sqlalchemy/__init__.py | 43 +++++++++++++++++++---- tests/test_signals.py | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/flask_sqlalchemy/__init__.py b/flask_sqlalchemy/__init__.py index 821a7b78..93e41c8f 100644 --- a/flask_sqlalchemy/__init__.py +++ b/flask_sqlalchemy/__init__.py @@ -166,13 +166,14 @@ class _SessionSignalEvents(object): @classmethod def register(cls, session): if not hasattr(session, '_model_changes'): - session._model_changes = {} + session._model_changes = [] event.listen(session, 'before_flush', cls.record_ops) event.listen(session, 'before_commit', cls.record_ops) event.listen(session, 'before_commit', cls.before_commit) event.listen(session, 'after_commit', cls.after_commit) event.listen(session, 'after_rollback', cls.after_rollback) + event.listen(session, 'after_transaction_create', cls.after_transaction_create) @classmethod def unregister(cls, session): @@ -184,6 +185,7 @@ def unregister(cls, session): event.remove(session, 'before_commit', cls.before_commit) event.remove(session, 'after_commit', cls.after_commit) event.remove(session, 'after_rollback', cls.after_rollback) + event.remove(session, 'after_transaction_create', cls.after_transaction_create) @staticmethod def record_ops(session, flush_context=None, instances=None): @@ -196,28 +198,54 @@ def record_ops(session, flush_context=None, instances=None): for target in targets: state = inspect(target) key = state.identity_key if state.has_identity else id(target) - d[key] = (target, operation) + d[-1][key] = (target, operation) + + @staticmethod + def after_transaction_create(session, transaction): + if transaction.parent and not transaction.nested: + return + + try: + d = session._model_changes + except AttributeError: + return + + d.append({}) @staticmethod def before_commit(session): + if session.transaction.nested: + return + try: d = session._model_changes except AttributeError: return if d: - before_models_committed.send(session.app, changes=list(d.values())) + for level in d[1:]: + d[0].update(level) + + if d[0]: + before_models_committed.send(session.app, changes=list(d[0].values())) @staticmethod def after_commit(session): + if session.transaction.nested: + return + try: d = session._model_changes except AttributeError: return if d: - models_committed.send(session.app, changes=list(d.values())) - d.clear() + for level in d[1:]: + d[0].update(level) + + if d[0]: + models_committed.send(session.app, changes=list(d[0].values())) + del d[:] @staticmethod def after_rollback(session): @@ -226,7 +254,10 @@ def after_rollback(session): except AttributeError: return - d.clear() + try: + del d[-1] + except IndexError: + pass class _EngineDebuggingSignalEvents(object): diff --git a/tests/test_signals.py b/tests/test_signals.py index fa6611ed..0f88ce26 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -2,6 +2,7 @@ import pytest import flask_sqlalchemy as fsa +import sqlalchemy as sa pytestmark = pytest.mark.skipif( @@ -16,6 +17,24 @@ def app(app): return app +@pytest.fixture() +def db(db): + # required for correct handling of nested transactions, see + # https://docs.sqlalchemy.org/en/rel_1_0/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl + @sa.event.listens_for(db.engine, "connect") + def do_connect(dbapi_connection, connection_record): + # disable pysqlite's emitting of the BEGIN statement entirely. + # also stops it from emitting COMMIT before any DDL. + dbapi_connection.isolation_level = None + + @sa.event.listens_for(db.engine, "begin") + def do_begin(conn): + # emit our own BEGIN + conn.execute("BEGIN") + + return db + + def test_before_committed(app, db, Todo): class Namespace(object): is_received = False @@ -59,3 +78,51 @@ def committed(sender, changes): assert recorded[0][0] == todo assert recorded[0][1] == 'delete' fsa.models_committed.disconnect(committed) + + +def test_model_signals_nested_transaction(db, Todo): + before_commit_recorded = [] + commit_recorded = [] + + def before_committed(sender, changes): + before_commit_recorded.extend(changes) + + def committed(sender, changes): + commit_recorded.extend(changes) + + fsa.before_models_committed.connect(before_committed) + fsa.models_committed.connect(committed) + with db.session.begin_nested(): + todo = Todo('Awesome', 'the text') + db.session.add(todo) + try: + with db.session.begin_nested(): + todo2 = Todo('Bad', 'to rollback') + db.session.add(todo2) + raise Exception('raising to roll back') + except Exception: + pass + assert before_commit_recorded == [] + assert commit_recorded == [] + db.session.commit() + assert before_commit_recorded == [(todo, 'insert')] + assert commit_recorded == [(todo, 'insert')] + del before_commit_recorded[:] + del commit_recorded[:] + try: + with db.session.begin_nested(): + todo = Todo('Great', 'the text') + db.session.add(todo) + with db.session.begin_nested(): + todo2 = Todo('Bad', 'to rollback') + db.session.add(todo2) + raise Exception('raising to roll back') + except Exception: + pass + assert before_commit_recorded == [] + assert commit_recorded == [] + db.session.commit() + assert before_commit_recorded == [] + assert commit_recorded == [] + fsa.before_models_committed.disconnect(before_committed) + fsa.models_committed.disconnect(committed)