Merge lp:~cjwatson/storm/restore-mysql into lp:storm

Proposed by Colin Watson
Status: Merged
Merged at revision: 568
Proposed branch: lp:~cjwatson/storm/restore-mysql
Merge into: lp:storm
Prerequisite: lp:~cjwatson/storm/py27-py35-workarounds
Diff against target: 995 lines (+708/-46)
13 files modified
.bzrignore (+1/-0)
MANIFEST.in (+1/-1)
NEWS (+1/-0)
README (+22/-41)
dev/test (+123/-2)
dev/ubuntu-deps (+3/-0)
setup.py (+2/-0)
storm/databases/mysql.py (+246/-0)
storm/docs/tutorial.rst (+2/-2)
storm/tests/databases/base.py (+2/-0)
storm/tests/databases/mysql.py (+167/-0)
storm/tests/store/base.py (+30/-0)
storm/tests/store/mysql.py (+108/-0)
To merge this branch: bzr merge lp:~cjwatson/storm/restore-mysql
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Storm Developers Pending
Review via email: mp+400633@code.launchpad.net

Commit message

Restore MySQL support.

Description of the change

We've had some requests to put this back, and it's reasonably straightforward to support. The only tricky bit was arranging for automatic test database provisioning; the only near-equivalent of postgresfixture that I could find is pytest-specific, but it's not too hard to inline the necessary setup and teardown code.

MySQL support was dropped in https://code.launchpad.net/~ack/storm/drop-mysql-support/+merge/288652, but it's clear that the people doing that work were labouring under the assumption that Storm is only used within Canonical, which is demonstrably untrue.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

https://paste.ubuntu.com/p/CpVtdGRZgm/ is a diff of the three "new" (restored) files in this branch against the revision immediately before their previous removal, which may make them easier to review. As you can see, there are a few changes for Python 3 support, test directory reorganization, and to support automatic test database provisioning, but they're relatively short.

Revision history for this message
Ioana Lasc (ilasc) wrote :

LGTM

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file '.bzrignore'
--- .bzrignore 2020-05-26 10:28:24 +0000
+++ .bzrignore 2021-04-06 09:31:39 +0000
@@ -3,6 +3,7 @@
3storm.egg-info3storm.egg-info
4build-stamp4build-stamp
5db5db
6db-mysql
6.db.lock7.db.lock
7dist8dist
8debian/files9debian/files
910
=== modified file 'MANIFEST.in'
--- MANIFEST.in 2020-05-26 10:28:24 +0000
+++ MANIFEST.in 2021-04-06 09:31:39 +0000
@@ -5,4 +5,4 @@
5include storm/docs/Makefile5include storm/docs/Makefile
6prune storm/docs/_build6prune storm/docs/_build
77
8prune db8prune db db-mysql
99
=== modified file 'NEWS'
--- NEWS 2020-12-07 22:38:36 +0000
+++ NEWS 2021-04-06 09:31:39 +0000
@@ -7,6 +7,7 @@
7- Add optional case_sensitive argument to Comparable.startswith,7- Add optional case_sensitive argument to Comparable.startswith,
8 Comparable.endswith, and Comparable.contains_string. This is only8 Comparable.endswith, and Comparable.contains_string. This is only
9 supported in the PostgreSQL backend.9 supported in the PostgreSQL backend.
10- Restore MySQL support.
1011
110.24 (2020-06-12)120.24 (2020-06-12)
12=================13=================
1314
=== modified file 'README'
--- README 2020-05-26 10:28:24 +0000
+++ README 2021-04-06 09:31:39 +0000
@@ -22,7 +22,8 @@
22 * Storm is well designed (different classes have very clear22 * Storm is well designed (different classes have very clear
23 boundaries, with small and clean public APIs).23 boundaries, with small and clean public APIs).
24 * Designed from day one to work both with thin relational24 * Designed from day one to work both with thin relational
25 databases, such as SQLite, and big iron systems like PostgreSQL.25 databases, such as SQLite, and big iron systems like PostgreSQL
26 and MySQL.
26 * Storm is easy to debug, since its code is written with a KISS27 * Storm is easy to debug, since its code is written with a KISS
27 principle, and thus is easy to understand.28 principle, and thus is easy to understand.
28 * Designed from day one to work both at the low end, with trivial29 * Designed from day one to work both at the low end, with trivial
@@ -89,6 +90,8 @@
89is below.90is below.
9091
91 $ dev/ubuntu-deps92 $ dev/ubuntu-deps
93 $ echo "$PWD/** rwk," | sudo tee /etc/apparmor.d/local/usr.sbin.mysqld >/dev/null
94 $ sudo aa-enforce /usr/sbin/mysqld
92 $ make develop95 $ make develop
93 $ make check96 $ make check
9497
@@ -103,15 +106,15 @@
103The following instructions assume that you're using Ubuntu. The same procedure106The following instructions assume that you're using Ubuntu. The same procedure
104will probably work without changes on a Debian system and with minimal changes107will probably work without changes on a Debian system and with minimal changes
105on a non-Debian-based linux distribution. In order to run the test suite, and108on a non-Debian-based linux distribution. In order to run the test suite, and
106exercise all supported backends, you will need to install PostgreSQL, along109exercise all supported backends, you will need to install MySQL and
107with the related Python database drivers:110PostgreSQL, along with the related Python database drivers:
108111
109 $ sudo apt-get install \112 $ sudo apt-get install \
113 mysql-server \
110 postgresql pgbouncer \114 postgresql pgbouncer \
111 build-essential115 build-essential
112116
113These will take a few minutes to download (its a bit under 200MB all117These will take a few minutes to download.
114together).
115118
116The Python dependencies for running tests can mostly be installed with119The Python dependencies for running tests can mostly be installed with
117apt-get:120apt-get:
@@ -135,42 +138,20 @@
135This ensures that all dependencies are available, downloading from138This ensures that all dependencies are available, downloading from
136PyPI as appropriate.139PyPI as appropriate.
137140
138Setting up database users and access security141Database setup
139---------------------------------------------142--------------
140143
141PostgreSQL needs to be setup to allow TCP/IP connections from144Most database setup is done automatically by the test suite. However,
142localhost. Edit /etc/postgresql/8.3/main/pg_hba.conf and make sure145Ubuntu's default MySQL packaging ships an AppArmor profile that prevents it
143the following line is present:146from writing to a local data directory. To allow the test suite to do this,
144147you will need to grant it access, which is most easily done by adding a line
145 host all all 127.0.0.1/32 trust148such as this to /etc/apparmor.d/local/usr.sbin.mysqld:
146149
147This will probably (with PostgresSQL 8.4) entail changing 'md5' to150 /path/to/storm/** rwk,
148'trust'.151
149152Then reload the profile:
150In order to run the two-phase commit tests, you will also need to153
151change the max_prepared_transactions value in postgres.conf to154 $ sudo aa-enforce /usr/sbin/mysqld
152something like
153
154 max_prepared_transactions = 200
155
156Now save and close, then restart the server:
157
158 $ sudo /etc/init.d/postgresql-8.4 restart
159
160Lets create our PostgreSQL user now. As noted in the Ubuntu PostgreSQL
161documentation, the easiest thing is to create a user with the same name as your
162username. Run the following command to create a user for yourself (if prompted
163for a password, leave it blank):
164
165 $ sudo -u postgres createuser --superuser $USER
166
167Creating test databases
168-----------------------
169
170The test suite needs some local databases in place to exercise PostgreSQL
171functionality. Run:
172
173 $ createdb storm_test
174155
175Running the tests156Running the tests
176-----------------157-----------------
177158
=== modified file 'dev/test'
--- dev/test 2019-11-21 01:57:01 +0000
+++ dev/test 2021-04-06 09:31:39 +0000
@@ -21,10 +21,16 @@
21#21#
22import glob22import glob
23import optparse23import optparse
24import os
24import re25import re
26import shutil
27import socket
28import subprocess
29import sys
30import time
25import unittest31import unittest
26import sys32
27import os33from pkg_resources import parse_version
2834
2935
30def add_eggs_to_path():36def add_eggs_to_path():
@@ -111,6 +117,120 @@
111 return wrapper117 return wrapper
112118
113119
120def with_mysql(runner_func):
121 """If possible, wrap a test runner with code to set up MySQL.
122
123 Loosely based on the approach taken by pytest-mysql, although
124 implemented separately.
125 """
126 try:
127 import MySQLdb
128 except ImportError:
129 return runner_func
130
131 from six.moves.urllib.parse import (
132 urlencode,
133 urlunsplit,
134 )
135
136 def wrapper():
137 basedir = os.path.abspath("db-mysql")
138 datadir = os.path.join(basedir, "data")
139 unix_socket = os.path.join(basedir, "mysql.sock")
140 logfile = os.path.join(basedir, "mysql.log")
141 if os.path.exists(basedir):
142 shutil.rmtree(basedir)
143 os.makedirs(basedir)
144
145 mysqld_version_output = subprocess.check_output(
146 ["mysqld", "--version"], universal_newlines=True).rstrip("\n")
147 version = re.search(
148 r"Ver ([\d.]+)", mysqld_version_output, flags=re.I).group(1)
149 if ("MariaDB" not in mysqld_version_output and
150 parse_version(version) >= parse_version("5.7.6")):
151 subprocess.check_call([
152 "mysqld",
153 "--initialize-insecure",
154 "--datadir=%s" % datadir,
155 "--tmpdir=%s" % basedir,
156 "--log-error=%s" % logfile,
157 ])
158 else:
159 subprocess.check_call([
160 "mysql_install_db",
161 "--datadir=%s" % datadir,
162 "--tmpdir=%s" % basedir,
163 ])
164 with open("/dev/null", "w") as devnull:
165 server_proc = subprocess.Popen([
166 "mysqld_safe",
167 "--datadir=%s" % datadir,
168 "--pid-file=%s" % os.path.join(basedir, "mysql.pid"),
169 "--socket=%s" % unix_socket,
170 "--skip-networking",
171 "--log-error=%s" % logfile,
172 "--tmpdir=%s" % basedir,
173 "--skip-syslog",
174 # We don't care about durability of test data. Try to
175 # persuade MySQL to agree.
176 "--innodb-doublewrite=0",
177 "--innodb-flush-log-at-trx-commit=0",
178 "--innodb-flush-method=O_DIRECT_NO_FSYNC",
179 "--skip-innodb-file-per-table",
180 "--sync-binlog=0",
181 ], stdout=devnull)
182
183 try:
184 start_time = time.time()
185 while time.time() < start_time + 60:
186 code = server_proc.poll()
187 if code is not None and code != 0:
188 raise Exception("mysqld_base exited %d" % code)
189
190 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
191 try:
192 sock.connect(unix_socket)
193 break
194 except socket.error:
195 pass # try again
196 finally:
197 sock.close()
198
199 time.sleep(0.1)
200
201 try:
202 connection = MySQLdb.connect(
203 user="root", unix_socket=unix_socket)
204 try:
205 cursor = connection.cursor()
206 try:
207 cursor.execute(
208 "CREATE DATABASE storm_test CHARACTER SET utf8;")
209 finally:
210 cursor.close()
211 finally:
212 connection.close()
213 uri = urlunsplit(
214 ("mysql", "root@localhost", "/storm_test",
215 urlencode({"unix_socket": unix_socket}), ""))
216 os.environ["STORM_MYSQL_URI"] = uri
217 os.environ["STORM_MYSQL_HOST_URI"] = uri
218 return runner_func()
219 finally:
220 subprocess.check_call([
221 "mysqladmin",
222 "--socket=%s" % unix_socket,
223 "--user=root",
224 "shutdown",
225 ])
226 finally:
227 if server_proc.poll() is None:
228 server_proc.kill()
229 server_proc.wait()
230
231 return wrapper
232
233
114if __name__ == "__main__":234if __name__ == "__main__":
115 runner = os.environ.get("STORM_TEST_RUNNER")235 runner = os.environ.get("STORM_TEST_RUNNER")
116 if not runner:236 if not runner:
@@ -119,6 +239,7 @@
119 if not runner_func:239 if not runner_func:
120 sys.exit("Test runner not found: %s" % runner)240 sys.exit("Test runner not found: %s" % runner)
121 runner_func = with_postgresfixture(runner_func)241 runner_func = with_postgresfixture(runner_func)
242 runner_func = with_mysql(runner_func)
122 sys.exit(runner_func())243 sys.exit(runner_func())
123244
124# vim:ts=4:sw=4:et245# vim:ts=4:sw=4:et
125246
=== modified file 'dev/ubuntu-deps'
--- dev/ubuntu-deps 2019-06-05 10:53:34 +0000
+++ dev/ubuntu-deps 2021-04-06 09:31:39 +0000
@@ -7,11 +7,14 @@
77
8apt_get install --no-install-recommends \8apt_get install --no-install-recommends \
9 build-essential \9 build-essential \
10 libmysqlclient-dev \
10 libpq-dev \11 libpq-dev \
12 mysql-server \
11 pgbouncer \13 pgbouncer \
12 postgresql \14 postgresql \
13 python-dev \15 python-dev \
14 python-fixtures \16 python-fixtures \
17 python-mysqldb \
15 python-psycopg2 \18 python-psycopg2 \
16 python-setuptools \19 python-setuptools \
17 python-six \20 python-six \
1821
=== modified file 'setup.py'
--- setup.py 2021-04-06 09:31:39 +0000
+++ setup.py 2021-04-06 09:31:39 +0000
@@ -25,6 +25,8 @@
2525
26tests_require = [26tests_require = [
27 "fixtures >= 1.3.0",27 "fixtures >= 1.3.0",
28 "mysqlclient",
29 "mysqlclient < 2.0.0; python_version < '3'",
28 "pgbouncer >= 0.0.7",30 "pgbouncer >= 0.0.7",
29 "postgresfixture",31 "postgresfixture",
30 "psycopg2 >= 2.3.0",32 "psycopg2 >= 2.3.0",
3133
=== added file 'storm/databases/mysql.py'
--- storm/databases/mysql.py 1970-01-01 00:00:00 +0000
+++ storm/databases/mysql.py 2021-04-06 09:31:39 +0000
@@ -0,0 +1,246 @@
1#
2# Copyright (c) 2006, 2007 Canonical
3#
4# Written by Gustavo Niemeyer <gustavo@niemeyer.net>
5#
6# This file is part of Storm Object Relational Mapper.
7#
8# Storm is free software; you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License as
10# published by the Free Software Foundation; either version 2.1 of
11# the License, or (at your option) any later version.
12#
13# Storm is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU Lesser General Public License for more details.
17#
18# You should have received a copy of the GNU Lesser General Public License
19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20#
21
22from __future__ import print_function
23
24from datetime import time, timedelta
25from array import array
26import sys
27
28from storm.databases import dummy
29
30try:
31 import MySQLdb
32 import MySQLdb.converters
33except ImportError:
34 MySQLdb = dummy
35
36from storm.database import (
37 Connection,
38 ConnectionWrapper,
39 Database,
40 Result,
41 )
42from storm.exceptions import (
43 DatabaseModuleError,
44 OperationalError,
45 wrap_exceptions,
46 )
47from storm.expr import (
48 compile,
49 compile_select,
50 Insert,
51 is_safe_token,
52 Select,
53 SQLToken,
54 Undef,
55 )
56from storm.variables import Variable
57
58
59compile = compile.create_child()
60
61@compile.when(Select)
62def compile_select_mysql(compile, select, state):
63 if select.offset is not Undef and select.limit is Undef:
64 select.limit = sys.maxsize
65 return compile_select(compile, select, state)
66
67@compile.when(SQLToken)
68def compile_sql_token_mysql(compile, expr, state):
69 """MySQL uses ` as the escape character by default."""
70 if is_safe_token(expr) and not compile.is_reserved_word(expr):
71 return expr
72 return '`%s`' % expr.replace('`', '``')
73
74
75class MySQLResult(Result):
76
77 @staticmethod
78 def from_database(row):
79 """Convert MySQL-specific datatypes to "normal" Python types.
80
81 If there are any C{array} instances in the row, convert them
82 to strings.
83 """
84 for value in row:
85 if isinstance(value, array):
86 yield value.tostring()
87 else:
88 yield value
89
90
91class MySQLConnection(Connection):
92
93 result_factory = MySQLResult
94 param_mark = "%s"
95 compile = compile
96
97 def execute(self, statement, params=None, noresult=False):
98 if (isinstance(statement, Insert) and
99 statement.primary_variables is not Undef):
100
101 result = Connection.execute(self, statement, params)
102
103 # The lastrowid value will be set if:
104 # - the table had an AUTO INCREMENT column, and
105 # - the column was not set during the insert or set to 0
106 #
107 # If these conditions are met, then lastrowid will be the
108 # value of the first such column set. We assume that it
109 # is the first undefined primary key variable.
110 if result._raw_cursor.lastrowid:
111 for variable in statement.primary_variables:
112 if not variable.is_defined():
113 variable.set(result._raw_cursor.lastrowid,
114 from_db=True)
115 break
116 if noresult:
117 result = None
118 return result
119 return Connection.execute(self, statement, params, noresult)
120
121 def to_database(self, params):
122 for param in params:
123 if isinstance(param, Variable):
124 param = param.get(to_db=True)
125 if isinstance(param, timedelta):
126 yield str(param)
127 else:
128 yield param
129
130 def is_disconnection_error(self, exc, extra_disconnection_errors=()):
131 # http://dev.mysql.com/doc/refman/5.0/en/gone-away.html
132 return (isinstance(exc, (OperationalError,
133 extra_disconnection_errors)) and
134 exc.args[0] in (2006, 2013)) # (SERVER_GONE_ERROR, SERVER_LOST)
135
136
137class MySQL(Database):
138
139 connection_factory = MySQLConnection
140 _exception_module = MySQLdb
141 _converters = None
142
143 def __init__(self, uri):
144 super(MySQL, self).__init__(uri)
145 if MySQLdb is dummy:
146 raise DatabaseModuleError("'MySQLdb' module not found")
147 self._connect_kwargs = {}
148 if uri.database is not None:
149 self._connect_kwargs["db"] = uri.database
150 if uri.host is not None:
151 self._connect_kwargs["host"] = uri.host
152 if uri.port is not None:
153 self._connect_kwargs["port"] = uri.port
154 if uri.username is not None:
155 self._connect_kwargs["user"] = uri.username
156 if uri.password is not None:
157 self._connect_kwargs["passwd"] = uri.password
158 for option in ["unix_socket"]:
159 if option in uri.options:
160 self._connect_kwargs[option] = uri.options.get(option)
161
162 if self._converters is None:
163 # MySQLdb returns a timedelta by default on TIME fields.
164 converters = MySQLdb.converters.conversions.copy()
165 converters[MySQLdb.converters.FIELD_TYPE.TIME] = _convert_time
166 self.__class__._converters = converters
167
168 self._connect_kwargs["conv"] = self._converters
169 self._connect_kwargs["use_unicode"] = True
170 self._connect_kwargs["charset"] = uri.options.get("charset", "utf8")
171
172 def _raw_connect(self):
173 raw_connection = ConnectionWrapper(
174 MySQLdb.connect(**self._connect_kwargs), self)
175
176 # Here is another sad story about bad transactional behavior. MySQL
177 # offers a feature to automatically reconnect dropped connections.
178 # What sounds like a dream, is actually a nightmare for anyone who
179 # is dealing with transactions. When a reconnection happens, the
180 # currently running transaction is transparently rolled back, and
181 # everything that was being done is lost, without notice. Not only
182 # that, but the connection may be put back in AUTOCOMMIT mode, even
183 # when that's not the default MySQLdb behavior. The MySQL developers
184 # quickly understood that this is a terrible idea, and removed the
185 # behavior in MySQL 5.0.3. Unfortunately, Debian and Ubuntu still
186 # have a patch for the MySQLdb module which *reenables* that
187 # behavior by default even past version 5.0.3 of MySQL.
188 #
189 # Some links:
190 # http://dev.mysql.com/doc/refman/5.0/en/auto-reconnect.html
191 # http://dev.mysql.com/doc/refman/5.0/en/mysql-reconnect.html
192 # http://dev.mysql.com/doc/refman/5.0/en/gone-away.html
193 #
194 # What we do here is to explore something that is a very weird
195 # side-effect, discovered by reading the code. When we call the
196 # ping() with a False argument, the automatic reconnection is
197 # disabled in a *permanent* way for this connection. The argument
198 # to ping() is new in 1.2.2, though.
199 if MySQLdb.version_info >= (1, 2, 2):
200 raw_connection.ping(False)
201
202 return raw_connection
203
204 def raw_connect(self):
205 with wrap_exceptions(self):
206 return self._raw_connect()
207
208
209create_from_uri = MySQL
210
211
212def _convert_time(time_str):
213 h, m, s = time_str.split(":")
214 if "." in s:
215 f = float(s)
216 s = int(f)
217 return time(int(h), int(m), s, (f-s)*1000000)
218 return time(int(h), int(m), int(s), 0)
219
220
221# --------------------------------------------------------------------
222# Reserved words, MySQL specific
223
224# The list of reserved words here are MySQL specific. SQL92 reserved words
225# are registered in storm.expr, near the "Reserved words, from SQL1992"
226# comment. The reserved words here were taken from:
227#
228# http://dev.mysql.com/doc/refman/5.4/en/reserved-words.html
229compile.add_reserved_words("""
230 accessible analyze asensitive before bigint binary blob call change
231 condition current_user database databases day_hour day_microsecond
232 day_minute day_second delayed deterministic distinctrow div dual each
233 elseif enclosed escaped exit explain float4 float8 force fulltext
234 high_priority hour_microsecond hour_minute hour_second if ignore index
235 infile inout int1 int2 int3 int4 int8 iterate keys kill leave limit linear
236 lines load localtime localtimestamp lock long longblob longtext loop
237 low_priority master_ssl_verify_server_cert mediumblob mediumint mediumtext
238 middleint minute_microsecond minute_second mod modifies no_write_to_binlog
239 optimize optionally out outfile purge range read_write reads regexp
240 release rename repeat replace require return rlike schemas
241 second_microsecond sensitive separator show spatial specific
242 sql_big_result sql_calc_found_rows sql_small_result sqlexception
243 sqlwarning ssl starting straight_join terminated tinyblob tinyint tinytext
244 trigger undo unlock unsigned use utc_date utc_time utc_timestamp varbinary
245 varcharacter while xor year_month zerofill
246 """.split())
0247
=== modified file 'storm/docs/tutorial.rst'
--- storm/docs/tutorial.rst 2020-05-26 10:28:24 +0000
+++ storm/docs/tutorial.rst 2021-04-06 09:31:39 +0000
@@ -36,7 +36,7 @@
36 >>> database = create_database("sqlite:")36 >>> database = create_database("sqlite:")
37 >>> store = Store(database)37 >>> store = Store(database)
3838
39Two databases are supported at the moment: SQLite and PostgreSQL.39Three databases are supported at the moment: SQLite, MySQL, and PostgreSQL.
40The parameter passed to :py:func:`~storm.database.create_database` is an40The parameter passed to :py:func:`~storm.database.create_database` is an
41URI, as follows:41URI, as follows:
4242
@@ -45,7 +45,7 @@
45 # database = create_database(45 # database = create_database(
46 # "scheme://username:password@hostname:port/database_name")46 # "scheme://username:password@hostname:port/database_name")
4747
48The ``scheme`` may be ``sqlite`` or ``postgres``.48The ``scheme`` may be ``sqlite``, ``mysql``, or ``postgres``.
4949
50Now we have to create the table that will actually hold the data50Now we have to create the table that will actually hold the data
51for our class.51for our class.
5252
=== modified file 'storm/tests/databases/base.py'
--- storm/tests/databases/base.py 2020-02-10 15:30:23 +0000
+++ storm/tests/databases/base.py 2021-04-06 09:31:39 +0000
@@ -333,6 +333,8 @@
333 that works with data in memory, in fact) becomes a dangerous thing.333 that works with data in memory, in fact) becomes a dangerous thing.
334334
335 For PostgreSQL, isolation level must be SERIALIZABLE.335 For PostgreSQL, isolation level must be SERIALIZABLE.
336 For MySQL, isolation level must be REPEATABLE READ (the default),
337 and the InnoDB engine must be in use.
336 For SQLite, the isolation level already is SERIALIZABLE when not338 For SQLite, the isolation level already is SERIALIZABLE when not
337 in autocommit mode. OTOH, PySQLite is nuts regarding transactional339 in autocommit mode. OTOH, PySQLite is nuts regarding transactional
338 behavior, and will easily offer READ COMMITTED behavior inside a340 behavior, and will easily offer READ COMMITTED behavior inside a
339341
=== added file 'storm/tests/databases/mysql.py'
--- storm/tests/databases/mysql.py 1970-01-01 00:00:00 +0000
+++ storm/tests/databases/mysql.py 2021-04-06 09:31:39 +0000
@@ -0,0 +1,167 @@
1#
2# Copyright (c) 2006, 2007 Canonical
3#
4# Written by Gustavo Niemeyer <gustavo@niemeyer.net>
5#
6# This file is part of Storm Object Relational Mapper.
7#
8# Storm is free software; you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License as
10# published by the Free Software Foundation; either version 2.1 of
11# the License, or (at your option) any later version.
12#
13# Storm is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU Lesser General Public License for more details.
17#
18# You should have received a copy of the GNU Lesser General Public License
19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20#
21from __future__ import print_function
22
23import os
24
25from six.moves.urllib.parse import urlunsplit
26
27from storm.databases.mysql import MySQL
28from storm.database import create_database
29from storm.expr import Column, Insert
30from storm.uri import URI
31from storm.variables import IntVariable, UnicodeVariable
32
33from storm.tests.databases.base import (
34 DatabaseTest, DatabaseDisconnectionTest, UnsupportedDatabaseTest)
35from storm.tests.databases.proxy import ProxyTCPServer
36from storm.tests.helper import TestHelper
37
38
39def create_proxy_and_uri(uri):
40 """Create a TCP proxy to a Unix-domain database identified by `uri`."""
41 proxy = ProxyTCPServer(uri.options["unix_socket"])
42 proxy_host, proxy_port = proxy.server_address
43 proxy_uri = URI(urlunsplit(
44 ("mysql", "%s:%s" % (proxy_host, proxy_port), "/storm_test",
45 "", "")))
46 return proxy, proxy_uri
47
48
49class MySQLTest(DatabaseTest, TestHelper):
50
51 supports_microseconds = False
52
53 def is_supported(self):
54 return bool(os.environ.get("STORM_MYSQL_URI"))
55
56 def create_database(self):
57 self.database = create_database(os.environ["STORM_MYSQL_URI"])
58
59 def create_tables(self):
60 self.connection.execute("CREATE TABLE number "
61 "(one INTEGER, two INTEGER, three INTEGER)")
62 self.connection.execute("CREATE TABLE test "
63 "(id INT AUTO_INCREMENT PRIMARY KEY,"
64 " title VARCHAR(50)) ENGINE=InnoDB")
65 self.connection.execute("CREATE TABLE datetime_test "
66 "(id INT AUTO_INCREMENT PRIMARY KEY,"
67 " dt TIMESTAMP, d DATE, t TIME, td TEXT) "
68 "ENGINE=InnoDB")
69 self.connection.execute("CREATE TABLE bin_test "
70 "(id INT AUTO_INCREMENT PRIMARY KEY,"
71 " b BLOB) ENGINE=InnoDB")
72
73 def test_wb_create_database(self):
74 database = create_database("mysql://un:pw@ht:12/db?unix_socket=us")
75 self.assertTrue(isinstance(database, MySQL))
76 for key, value in [("db", "db"), ("host", "ht"), ("port", 12),
77 ("user", "un"), ("passwd", "pw"),
78 ("unix_socket", "us")]:
79 self.assertEqual(database._connect_kwargs.get(key), value)
80
81 def test_charset_defaults_to_utf8(self):
82 result = self.connection.execute("SELECT @@character_set_client")
83 self.assertEqual(result.get_one(), ("utf8",))
84
85 def test_charset_option(self):
86 uri = URI(os.environ["STORM_MYSQL_URI"])
87 uri.options["charset"] = "ascii"
88 database = create_database(uri)
89 connection = database.connect()
90 result = connection.execute("SELECT @@character_set_client")
91 self.assertEqual(result.get_one(), ("ascii",))
92
93 def test_get_insert_identity(self):
94 # Primary keys are filled in during execute() for MySQL
95 pass
96
97 def test_get_insert_identity_composed(self):
98 # Primary keys are filled in during execute() for MySQL
99 pass
100
101 def test_execute_insert_auto_increment_primary_key(self):
102 id_column = Column("id", "test")
103 id_variable = IntVariable()
104 title_column = Column("title", "test")
105 title_variable = UnicodeVariable(u"testing")
106
107 # This is not part of the table. It is just used to show that
108 # only one primary key variable is set from the insert ID.
109 dummy_column = Column("dummy", "test")
110 dummy_variable = IntVariable()
111
112 insert = Insert({title_column: title_variable},
113 primary_columns=(id_column, dummy_column),
114 primary_variables=(id_variable, dummy_variable))
115 self.connection.execute(insert)
116 self.assertTrue(id_variable.is_defined())
117 self.assertFalse(dummy_variable.is_defined())
118
119 # The newly inserted row should have the maximum id value for
120 # the table.
121 result = self.connection.execute("SELECT MAX(id) FROM test")
122 self.assertEqual(result.get_one()[0], id_variable.get())
123
124 def test_mysql_specific_reserved_words(self):
125 reserved_words = """
126 accessible analyze asensitive before bigint binary blob call
127 change condition current_user database databases day_hour
128 day_microsecond day_minute day_second delayed deterministic
129 distinctrow div dual each elseif enclosed escaped exit explain
130 float4 float8 force fulltext high_priority hour_microsecond
131 hour_minute hour_second if ignore index infile inout int1 int2
132 int3 int4 int8 iterate keys kill leave limit linear lines load
133 localtime localtimestamp lock long longblob longtext loop
134 low_priority master_ssl_verify_server_cert mediumblob mediumint
135 mediumtext middleint minute_microsecond minute_second mod modifies
136 no_write_to_binlog optimize optionally out outfile purge range
137 read_write reads regexp release rename repeat replace require
138 return rlike schemas second_microsecond sensitive separator show
139 spatial specific sql_big_result sql_calc_found_rows
140 sql_small_result sqlexception sqlwarning ssl starting
141 straight_join terminated tinyblob tinyint tinytext trigger undo
142 unlock unsigned use utc_date utc_time utc_timestamp varbinary
143 varcharacter while xor year_month zerofill
144 """.split()
145 for word in reserved_words:
146 self.assertTrue(self.connection.compile.is_reserved_word(word),
147 "Word missing: %s" % (word,))
148
149
150class MySQLUnsupportedTest(UnsupportedDatabaseTest, TestHelper):
151
152 dbapi_module_names = ["MySQLdb"]
153 db_module_name = "mysql"
154
155
156class MySQLDisconnectionTest(DatabaseDisconnectionTest, TestHelper):
157
158 environment_variable = "STORM_MYSQL_URI"
159 host_environment_variable = "STORM_MYSQL_HOST_URI"
160 default_port = 3306
161
162 def create_proxy(self, uri):
163 """See `DatabaseDisconnectionMixin.create_proxy`."""
164 if "unix_socket" in uri.options:
165 return create_proxy_and_uri(uri)[0]
166 else:
167 return super(MySQLDisconnectionTest, self).create_proxy(uri)
0168
=== modified file 'storm/tests/store/base.py'
--- storm/tests/store/base.py 2020-03-18 16:50:12 +0000
+++ storm/tests/store/base.py 2021-04-06 09:31:39 +0000
@@ -901,6 +901,8 @@
901 result2 = self.store.find(Foo, Foo.id != 10)901 result2 = self.store.find(Foo, Foo.id != 10)
902 self.assertEqual(foo in result1.union(result2), True)902 self.assertEqual(foo in result1.union(result2), True)
903903
904 if self.__class__.__name__.startswith("MySQL"):
905 return
904 self.assertEqual(foo in result1.intersection(result2), False)906 self.assertEqual(foo in result1.intersection(result2), False)
905 self.assertEqual(foo in result1.intersection(result1), True)907 self.assertEqual(foo in result1.intersection(result1), True)
906 self.assertEqual(foo in result1.difference(result2), True)908 self.assertEqual(foo in result1.difference(result2), True)
@@ -1352,6 +1354,8 @@
1352 result2 = self.store.find((Foo, Bar), Bar.foo_id == Foo.id)1354 result2 = self.store.find((Foo, Bar), Bar.foo_id == Foo.id)
1353 self.assertEqual((foo, bar) in result1.union(result2), True)1355 self.assertEqual((foo, bar) in result1.union(result2), True)
13541356
1357 if self.__class__.__name__.startswith("MySQL"):
1358 return
1355 self.assertEqual((foo, bar) in result1.intersection(result2), True)1359 self.assertEqual((foo, bar) in result1.intersection(result2), True)
1356 self.assertEqual((foo, bar) in result1.difference(result2), False)1360 self.assertEqual((foo, bar) in result1.difference(result2), False)
13571361
@@ -1468,6 +1472,8 @@
1468 result2 = self.store.find(Foo.title)1472 result2 = self.store.find(Foo.title)
1469 self.assertEqual(u"Title 10" in result1.union(result2), True)1473 self.assertEqual(u"Title 10" in result1.union(result2), True)
14701474
1475 if self.__class__.__name__.startswith("MySQL"):
1476 return
1471 self.assertEqual(u"Title 10" in result1.intersection(result2), True)1477 self.assertEqual(u"Title 10" in result1.intersection(result2), True)
1472 self.assertEqual(u"Title 10" in result1.difference(result2), False)1478 self.assertEqual(u"Title 10" in result1.difference(result2), False)
14731479
@@ -5733,6 +5739,9 @@
5733 self.assertEqual(result3.avg(Foo.id), 10)5739 self.assertEqual(result3.avg(Foo.id), 10)
57345740
5735 def test_result_difference(self):5741 def test_result_difference(self):
5742 if self.__class__.__name__.startswith("MySQL"):
5743 self.skipTest("Skipping ResultSet.difference tests on MySQL")
5744
5736 result1 = self.store.find(Foo)5745 result1 = self.store.find(Foo)
5737 result2 = self.store.find(Foo, id=20)5746 result2 = self.store.find(Foo, id=20)
5738 result3 = result1.difference(result2)5747 result3 = result1.difference(result2)
@@ -5750,6 +5759,9 @@
5750 ])5759 ])
57515760
5752 def test_result_difference_with_empty(self):5761 def test_result_difference_with_empty(self):
5762 if self.__class__.__name__.startswith("MySQL"):
5763 self.skipTest("Skipping ResultSet.difference tests on MySQL")
5764
5753 result1 = self.store.find(Foo, id=30)5765 result1 = self.store.find(Foo, id=30)
5754 result2 = EmptyResultSet()5766 result2 = EmptyResultSet()
57555767
@@ -5760,11 +5772,17 @@
5760 ])5772 ])
57615773
5762 def test_result_difference_incompatible(self):5774 def test_result_difference_incompatible(self):
5775 if self.__class__.__name__.startswith("MySQL"):
5776 self.skipTest("Skipping ResultSet.difference tests on MySQL")
5777
5763 result1 = self.store.find(Foo, id=10)5778 result1 = self.store.find(Foo, id=10)
5764 result2 = self.store.find(Bar, id=100)5779 result2 = self.store.find(Bar, id=100)
5765 self.assertRaises(FeatureError, result1.difference, result2)5780 self.assertRaises(FeatureError, result1.difference, result2)
57665781
5767 def test_result_difference_count(self):5782 def test_result_difference_count(self):
5783 if self.__class__.__name__.startswith("MySQL"):
5784 self.skipTest("Skipping ResultSet.difference tests on MySQL")
5785
5768 result1 = self.store.find(Foo)5786 result1 = self.store.find(Foo)
5769 result2 = self.store.find(Foo, id=20)5787 result2 = self.store.find(Foo, id=20)
57705788
@@ -5782,6 +5800,9 @@
5782 self.assertEqual(result2.count(), 3)5800 self.assertEqual(result2.count(), 3)
57835801
5784 def test_result_intersection(self):5802 def test_result_intersection(self):
5803 if self.__class__.__name__.startswith("MySQL"):
5804 self.skipTest("Skipping ResultSet.intersection tests on MySQL")
5805
5785 result1 = self.store.find(Foo)5806 result1 = self.store.find(Foo)
5786 result2 = self.store.find(Foo, Foo.id.is_in((10, 30)))5807 result2 = self.store.find(Foo, Foo.id.is_in((10, 30)))
5787 result3 = result1.intersection(result2)5808 result3 = result1.intersection(result2)
@@ -5799,6 +5820,9 @@
5799 ])5820 ])
58005821
5801 def test_result_intersection_with_empty(self):5822 def test_result_intersection_with_empty(self):
5823 if self.__class__.__name__.startswith("MySQL"):
5824 self.skipTest("Skipping ResultSet.intersection tests on MySQL")
5825
5802 result1 = self.store.find(Foo, id=30)5826 result1 = self.store.find(Foo, id=30)
5803 result2 = EmptyResultSet()5827 result2 = EmptyResultSet()
5804 result3 = result1.intersection(result2)5828 result3 = result1.intersection(result2)
@@ -5806,11 +5830,17 @@
5806 self.assertEqual(len(list(result3)), 0)5830 self.assertEqual(len(list(result3)), 0)
58075831
5808 def test_result_intersection_incompatible(self):5832 def test_result_intersection_incompatible(self):
5833 if self.__class__.__name__.startswith("MySQL"):
5834 self.skipTest("Skipping ResultSet.intersection tests on MySQL")
5835
5809 result1 = self.store.find(Foo, id=10)5836 result1 = self.store.find(Foo, id=10)
5810 result2 = self.store.find(Bar, id=100)5837 result2 = self.store.find(Bar, id=100)
5811 self.assertRaises(FeatureError, result1.intersection, result2)5838 self.assertRaises(FeatureError, result1.intersection, result2)
58125839
5813 def test_result_intersection_count(self):5840 def test_result_intersection_count(self):
5841 if self.__class__.__name__.startswith("MySQL"):
5842 self.skipTest("Skipping ResultSet.intersection tests on MySQL")
5843
5814 result1 = self.store.find(Foo, Foo.id.is_in((10, 20)))5844 result1 = self.store.find(Foo, Foo.id.is_in((10, 20)))
5815 result2 = self.store.find(Foo, Foo.id.is_in((10, 30)))5845 result2 = self.store.find(Foo, Foo.id.is_in((10, 30)))
5816 result3 = result1.intersection(result2)5846 result3 = result1.intersection(result2)
58175847
=== added file 'storm/tests/store/mysql.py'
--- storm/tests/store/mysql.py 1970-01-01 00:00:00 +0000
+++ storm/tests/store/mysql.py 2021-04-06 09:31:39 +0000
@@ -0,0 +1,108 @@
1#
2# Copyright (c) 2006, 2007 Canonical
3#
4# Written by Gustavo Niemeyer <gustavo@niemeyer.net>
5#
6# This file is part of Storm Object Relational Mapper.
7#
8# Storm is free software; you can redistribute it and/or modify
9# it under the terms of the GNU Lesser General Public License as
10# published by the Free Software Foundation; either version 2.1 of
11# the License, or (at your option) any later version.
12#
13# Storm is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU Lesser General Public License for more details.
17#
18# You should have received a copy of the GNU Lesser General Public License
19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20#
21from __future__ import print_function
22
23import os
24
25from storm.database import create_database
26
27from storm.tests.store.base import StoreTest, EmptyResultSetTest
28from storm.tests.helper import TestHelper
29
30
31class MySQLStoreTest(TestHelper, StoreTest):
32
33 def setUp(self):
34 TestHelper.setUp(self)
35 StoreTest.setUp(self)
36
37 def tearDown(self):
38 TestHelper.tearDown(self)
39 StoreTest.tearDown(self)
40
41 def is_supported(self):
42 return bool(os.environ.get("STORM_MYSQL_URI"))
43
44 def create_database(self):
45 self.database = create_database(os.environ["STORM_MYSQL_URI"])
46
47 def create_tables(self):
48 connection = self.connection
49 connection.execute("CREATE TABLE foo "
50 "(id INT PRIMARY KEY AUTO_INCREMENT,"
51 " title VARCHAR(50) DEFAULT 'Default Title') "
52 "ENGINE=InnoDB")
53 connection.execute("CREATE TABLE bar "
54 "(id INT PRIMARY KEY AUTO_INCREMENT,"
55 " foo_id INTEGER, title VARCHAR(50)) "
56 "ENGINE=InnoDB")
57 connection.execute("CREATE TABLE bin "
58 "(id INT PRIMARY KEY AUTO_INCREMENT,"
59 " bin BLOB, foo_id INTEGER) "
60 "ENGINE=InnoDB")
61 connection.execute("CREATE TABLE link "
62 "(foo_id INTEGER, bar_id INTEGER,"
63 " PRIMARY KEY (foo_id, bar_id)) "
64 "ENGINE=InnoDB")
65 connection.execute("CREATE TABLE money "
66 "(id INT PRIMARY KEY AUTO_INCREMENT,"
67 " value NUMERIC(6,4)) "
68 "ENGINE=InnoDB")
69 connection.execute("CREATE TABLE selfref "
70 "(id INT PRIMARY KEY AUTO_INCREMENT,"
71 " title VARCHAR(50),"
72 " selfref_id INTEGER,"
73 " INDEX (selfref_id),"
74 " FOREIGN KEY (selfref_id) REFERENCES selfref(id)) "
75 "ENGINE=InnoDB")
76 connection.execute("CREATE TABLE foovalue "
77 "(id INT PRIMARY KEY AUTO_INCREMENT,"
78 " foo_id INTEGER,"
79 " value1 INTEGER, value2 INTEGER) "
80 "ENGINE=InnoDB")
81 connection.execute("CREATE TABLE unique_id "
82 "(id VARCHAR(36) PRIMARY KEY) "
83 "ENGINE=InnoDB")
84 connection.commit()
85
86
87class MySQLEmptyResultSetTest(TestHelper, EmptyResultSetTest):
88
89 def setUp(self):
90 TestHelper.setUp(self)
91 EmptyResultSetTest.setUp(self)
92
93 def tearDown(self):
94 TestHelper.tearDown(self)
95 EmptyResultSetTest.tearDown(self)
96
97 def is_supported(self):
98 return bool(os.environ.get("STORM_MYSQL_URI"))
99
100 def create_database(self):
101 self.database = create_database(os.environ["STORM_MYSQL_URI"])
102
103 def create_tables(self):
104 self.connection.execute("CREATE TABLE foo "
105 "(id INT PRIMARY KEY AUTO_INCREMENT,"
106 " title VARCHAR(50) DEFAULT 'Default Title') "
107 "ENGINE=InnoDB")
108 self.connection.commit()

Subscribers

People subscribed via source and target branches

to status/vote changes: