Skip to content

Commit e507833

Browse files
committed
make SA engine configuration more flexible
refs pallets-eco#166
1 parent eebe0f5 commit e507833

File tree

4 files changed

+179
-15
lines changed

4 files changed

+179
-15
lines changed

docs/config.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.. currentmodule:: flask_sqlalchemy
2+
13
Configuration
24
=============
35

@@ -63,6 +65,9 @@ A list of configuration keys currently understood by the extension:
6365
that it will be disabled by default in
6466
the future. This requires extra memory
6567
and should be disabled if not needed.
68+
``SQLALCHEMY_ENGINE_OPTIONS`` A dictionary of keyword args to send to
69+
:func:`~sqlalchemy.create_engine`. See
70+
also ``engine_options`` to :class:`SQLAlchemy`.
6671
================================== =========================================
6772

6873
.. versionadded:: 0.8
@@ -78,9 +83,13 @@ A list of configuration keys currently understood by the extension:
7883

7984
.. versionadded:: 2.0
8085
The ``SQLALCHEMY_TRACK_MODIFICATIONS`` configuration key was added.
86+
8187
.. versionchanged:: 2.1
8288
``SQLALCHEMY_TRACK_MODIFICATIONS`` will warn if unset.
8389

90+
.. versionchanged:: 2.4
91+
``SQLALCHEMY_ENGINE_OPTIONS`` configuration key was added.
92+
8493
Connection URI Format
8594
---------------------
8695

flask_sqlalchemy/__init__.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -553,19 +553,35 @@ def get_engine(self):
553553
echo = self._app.config['SQLALCHEMY_ECHO']
554554
if (uri, echo) == self._connected_for:
555555
return self._engine
556-
info = make_url(uri)
557-
options = {'convert_unicode': True}
558-
self._sa.apply_pool_defaults(self._app, options)
559-
self._sa.apply_driver_hacks(self._app, info, options)
560-
if echo:
561-
options['echo'] = echo
562-
self._engine = rv = sqlalchemy.create_engine(info, **options)
556+
557+
sa_url = make_url(uri)
558+
options = self.get_options(sa_url, echo)
559+
self._engine = rv = self._sa.create_engine(sa_url, options)
560+
563561
if _record_queries(self._app):
564562
_EngineDebuggingSignalEvents(self._engine,
565563
self._app.import_name).register()
564+
566565
self._connected_for = (uri, echo)
566+
567567
return rv
568568

569+
def get_options(self, sa_url, echo):
570+
options = {'convert_unicode': True}
571+
self._sa.apply_pool_defaults(self._app, options)
572+
self._sa.apply_driver_hacks(self._app, sa_url, options)
573+
if echo:
574+
options['echo'] = echo
575+
576+
# Give the config options set by a developer explicitly priority
577+
# over decisions FSA makes.
578+
options.update(self._app.config['SQLALCHEMY_ENGINE_OPTIONS'])
579+
580+
# Give options set in SQLAlchemy.__init__() ultimate priority
581+
options.update(self._sa._engine_options)
582+
583+
return options
584+
569585

570586
def get_state(app):
571587
"""Gets the state for the application"""
@@ -644,6 +660,12 @@ class User(db.Model):
644660
to be passed to the session constructor. See :class:`~sqlalchemy.orm.session.Session`
645661
for the standard options.
646662
663+
The ``engine_options`` parameter, if provided, is a dict of parameters
664+
to be passed to create engine. See :func:`~sqlalchemy.create_engine`
665+
for the standard options. The values given here will be merged with and
666+
override anything set in the ``'SQLALCHEMY_ENGINE_OPTIONS'`` config
667+
variable or othewise set by this library.
668+
647669
.. versionadded:: 0.10
648670
The `session_options` parameter was added.
649671
@@ -663,6 +685,9 @@ class to be used in place of :class:`Model`.
663685
664686
.. versionchanged:: 2.1
665687
Utilise the same query class across `session`, `Model.query` and `Query`.
688+
689+
.. versionadded:: 2.4
690+
The `engine_options` parameter was added.
666691
"""
667692

668693
#: Default query class used by :attr:`Model.query` and other queries.
@@ -671,14 +696,16 @@ class to be used in place of :class:`Model`.
671696
Query = None
672697

673698
def __init__(self, app=None, use_native_unicode=True, session_options=None,
674-
metadata=None, query_class=BaseQuery, model_class=Model):
699+
metadata=None, query_class=BaseQuery, model_class=Model,
700+
engine_options=None):
675701

676702
self.use_native_unicode = use_native_unicode
677703
self.Query = query_class
678704
self.session = self.create_scoped_session(session_options)
679705
self.Model = self.make_declarative_base(model_class, metadata)
680706
self._engine_lock = Lock()
681707
self.app = app
708+
self._engine_options = engine_options or {}
682709
_include_sqlalchemy(self, query_class)
683710

684711
if app is not None:
@@ -790,6 +817,7 @@ def init_app(self, app):
790817
track_modifications = app.config.setdefault(
791818
'SQLALCHEMY_TRACK_MODIFICATIONS', None
792819
)
820+
app.config.setdefault('SQLALCHEMY_ENGINE_OPTIONS', {})
793821

794822
if track_modifications is None:
795823
warnings.warn(FSADeprecationWarning(
@@ -819,7 +847,7 @@ def _setdefault(optionkey, configkey):
819847
_setdefault('pool_recycle', 'SQLALCHEMY_POOL_RECYCLE')
820848
_setdefault('max_overflow', 'SQLALCHEMY_MAX_OVERFLOW')
821849

822-
def apply_driver_hacks(self, app, info, options):
850+
def apply_driver_hacks(self, app, sa_url, options):
823851
"""This method is called before engine creation and used to inject
824852
driver specific hacks into the options. The `options` parameter is
825853
a dictionary of keyword arguments that will then be used to call
@@ -829,15 +857,15 @@ def apply_driver_hacks(self, app, info, options):
829857
like pool sizes for MySQL and sqlite. Also it injects the setting of
830858
`SQLALCHEMY_NATIVE_UNICODE`.
831859
"""
832-
if info.drivername.startswith('mysql'):
833-
info.query.setdefault('charset', 'utf8')
834-
if info.drivername != 'mysql+gaerdbms':
860+
if sa_url.drivername.startswith('mysql'):
861+
sa_url.query.setdefault('charset', 'utf8')
862+
if sa_url.drivername != 'mysql+gaerdbms':
835863
options.setdefault('pool_size', 10)
836864
options.setdefault('pool_recycle', 7200)
837-
elif info.drivername == 'sqlite':
865+
elif sa_url.drivername == 'sqlite':
838866
pool_size = options.get('pool_size')
839867
detected_in_memory = False
840-
if info.database in (None, '', ':memory:'):
868+
if sa_url.database in (None, '', ':memory:'):
841869
detected_in_memory = True
842870
from sqlalchemy.pool import StaticPool
843871
options['poolclass'] = StaticPool
@@ -860,7 +888,7 @@ def apply_driver_hacks(self, app, info, options):
860888

861889
# if it's not an in memory database we make the path absolute.
862890
if not detected_in_memory:
863-
info.database = os.path.join(app.root_path, info.database)
891+
sa_url.database = os.path.join(app.root_path, sa_url.database)
864892

865893
unu = app.config['SQLALCHEMY_NATIVE_UNICODE']
866894
if unu is None:
@@ -897,6 +925,16 @@ def get_engine(self, app=None, bind=None):
897925

898926
return connector.get_engine()
899927

928+
def create_engine(self, sa_url, engine_opts):
929+
"""
930+
Override this method to have final say over how the SQLAlchemy engine
931+
is created.
932+
933+
In most cases, you will want to use ``'SQLALCHEMY_ENGINE_OPTIONS'``
934+
config variable or set ``engine_options`` for :func:`SQLAlchemy`.
935+
"""
936+
return sqlalchemy.create_engine(sa_url, **engine_opts)
937+
900938
def get_app(self, reference_app=None):
901939
"""Helper method that implements the logic to look up an
902940
application."""

tests/test_config.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import flask_sqlalchemy as fsa
2+
3+
import mock
4+
import pytest
5+
6+
7+
class TestConfigKeys:
8+
9+
def test_defaults(self, app):
10+
"""
11+
Test all documented config values in the order they appear in our
12+
documentation: http://flask-sqlalchemy.pocoo.org/dev/config/
13+
"""
14+
# Our pytest fixture for creating the app sets
15+
# SQLALCHEMY_TRACK_MODIFICATIONS, so we undo that here so that we
16+
# can inspect what FSA does below:
17+
del app.config['SQLALCHEMY_TRACK_MODIFICATIONS']
18+
19+
with pytest.warns(fsa.FSADeprecationWarning) as records:
20+
fsa.SQLAlchemy(app)
21+
22+
# Only expecting one warning for the track modifications deprecation.
23+
assert len(records) == 1
24+
25+
assert app.config['SQLALCHEMY_DATABASE_URI'] == 'sqlite:///:memory:'
26+
assert app.config['SQLALCHEMY_BINDS'] is None
27+
assert app.config['SQLALCHEMY_ECHO'] is False
28+
assert app.config['SQLALCHEMY_RECORD_QUERIES'] is None
29+
assert app.config['SQLALCHEMY_NATIVE_UNICODE'] is None
30+
assert app.config['SQLALCHEMY_POOL_SIZE'] is None
31+
assert app.config['SQLALCHEMY_POOL_TIMEOUT'] is None
32+
assert app.config['SQLALCHEMY_POOL_RECYCLE'] is None
33+
assert app.config['SQLALCHEMY_MAX_OVERFLOW'] is None
34+
assert app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] is None
35+
assert app.config['SQLALCHEMY_ENGINE_OPTIONS'] == {}
36+
37+
def test_track_modifications_warning(self, app, recwarn):
38+
39+
# pytest fixuture sets SQLALCHEMY_TRACK_MODIFICATIONS = False
40+
fsa.SQLAlchemy(app)
41+
42+
# So we shouldn't have any warnings
43+
assert len(recwarn) == 0
44+
45+
# Let's trigger the warning
46+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = None
47+
fsa.SQLAlchemy(app)
48+
49+
# and verify it showed up as expected
50+
assert len(recwarn) == 1
51+
expect = 'SQLALCHEMY_TRACK_MODIFICATIONS adds significant overhead' \
52+
' and will be disabled by default in the future. Set it' \
53+
' to True or False to suppress this warning.'
54+
assert recwarn[0].message.args[0] == expect
55+
56+
def test_uri_binds_warning(self, app, recwarn):
57+
# Let's trigger the warning
58+
del app.config['SQLALCHEMY_DATABASE_URI']
59+
fsa.SQLAlchemy(app)
60+
61+
# and verify it showed up as expected
62+
assert len(recwarn) == 1
63+
expect = 'Neither SQLALCHEMY_DATABASE_URI nor SQLALCHEMY_BINDS' \
64+
' is set. Defaulting SQLALCHEMY_DATABASE_URI to'\
65+
' "sqlite:///:memory:".'
66+
assert recwarn[0].message.args[0] == expect
67+
68+
69+
@pytest.fixture
70+
def app_nr(app):
71+
"""
72+
Signal/event registration with record queries breaks when
73+
sqlalchemy.create_engine() is mocked out.
74+
"""
75+
app.config['SQLALCHEMY_RECORD_QUERIES'] = False
76+
return app
77+
78+
79+
@mock.patch.object(fsa.sqlalchemy, 'create_engine', autospec=True, spec_set=True)
80+
class TestCreateEngine:
81+
"""
82+
Tests for _EngineConnector and SQLAlchemy methods inolved in setting up
83+
the SQLAlchemy engine.
84+
"""
85+
86+
def test_engine_echo_default(self, m_create_engine, app_nr):
87+
fsa.SQLAlchemy(app_nr).get_engine()
88+
89+
args, options = m_create_engine.call_args
90+
assert 'echo' not in options
91+
92+
def test_engine_echo_true(self, m_create_engine, app_nr):
93+
app_nr.config['SQLALCHEMY_ECHO'] = True
94+
fsa.SQLAlchemy(app_nr).get_engine()
95+
96+
args, options = m_create_engine.call_args
97+
assert options['echo'] is True
98+
99+
def test_convert_unicode_default(self, m_create_engine, app_nr):
100+
fsa.SQLAlchemy(app_nr).get_engine()
101+
102+
args, options = m_create_engine.call_args
103+
assert options['convert_unicode'] is True
104+
105+
def test_config_from_engine_options(self, m_create_engine, app_nr):
106+
app_nr.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'foo': 'bar'}
107+
fsa.SQLAlchemy(app_nr).get_engine()
108+
109+
args, options = m_create_engine.call_args
110+
assert options['foo'] == 'bar'
111+
112+
def test_config_from_init(self, m_create_engine, app_nr):
113+
fsa.SQLAlchemy(app_nr, engine_options={'bar': 'baz'}).get_engine()
114+
115+
args, options = m_create_engine.call_args
116+
assert options['bar'] == 'baz'

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ deps =
1515
pytest>=3
1616
coverage
1717
blinker
18+
mock
1819

1920
py27-lowest,py36-lowest: flask==0.12
2021
py27-lowest,py36-lowest: sqlalchemy==0.8

0 commit comments

Comments
 (0)