diff options
-rw-r--r-- | doc/build/changelog/changelog_10.rst | 19 | ||||
-rw-r--r-- | doc/build/changelog/migration_10.rst | 23 | ||||
-rw-r--r-- | lib/sqlalchemy/dialects/mysql/base.py | 88 | ||||
-rw-r--r-- | test/dialect/mysql/test_reflection.py | 55 | ||||
-rw-r--r-- | test/dialect/mysql/test_types.py | 107 |
5 files changed, 215 insertions, 77 deletions
diff --git a/doc/build/changelog/changelog_10.rst b/doc/build/changelog/changelog_10.rst index 89ef86844..ca0ae4d52 100644 --- a/doc/build/changelog/changelog_10.rst +++ b/doc/build/changelog/changelog_10.rst @@ -1,4 +1,5 @@ + ============== 1.0 Changelog ============== @@ -23,6 +24,24 @@ on compatibility concerns, see :doc:`/changelog/migration_10`. .. change:: + :tags: feature, mysql + :tickets: 3155 + + The MySQL dialect now renders TIMESTAMP with NULL / NOT NULL in + all cases, so that MySQL 5.6.6 with the + ``explicit_defaults_for_timestamp`` flag enabled will + will allow TIMESTAMP to continue to work as expected when + ``nullable=False``. Existing applications are unaffected as + SQLAlchemy has always emitted NULL for a TIMESTAMP column that + is ``nullable=True``. + + .. seealso:: + + :ref:`change_3155` + + :ref:`mysql_timestamp_null` + + .. change:: :tags: bug, schema :tickets: 3299, 3067 diff --git a/doc/build/changelog/migration_10.rst b/doc/build/changelog/migration_10.rst index 3ba0743f7..efb4b26e5 100644 --- a/doc/build/changelog/migration_10.rst +++ b/doc/build/changelog/migration_10.rst @@ -1817,6 +1817,29 @@ Support for the pypy psycopg2cffi dialect is added. Dialect Improvements and Changes - MySQL ============================================= +.. _change_3155: + +MySQL TIMESTAMP Type now renders NULL / NOT NULL in all cases +-------------------------------------------------------------- + +The MySQL dialect has always worked around MySQL's implicit NOT NULL +default associated with TIMESTAMP columns by emitting NULL for +such a type, if the column is set up with ``nullable=True``. However, +MySQL 5.6.6 and above features a new flag +`explicit_defaults_for_timestamp <http://dev.mysql.com/doc/refman/ +5.6/en/server-system-variables.html +#sysvar_explicit_defaults_for_timestamp>`_ which repairs MySQL's non-standard +behavior to make it behave like any other type; to accommodate this, +SQLAlchemy now emits NULL/NOT NULL unconditionally for all TIMESTAMP +columns. + +.. seealso:: + + :ref:`mysql_timestamp_null` + +:ticket:`3155` + + .. _change_3283: MySQL SET Type Overhauled to support empty sets, unicode, blank value handling diff --git a/lib/sqlalchemy/dialects/mysql/base.py b/lib/sqlalchemy/dialects/mysql/base.py index c8e33bfb2..cbb108f5e 100644 --- a/lib/sqlalchemy/dialects/mysql/base.py +++ b/lib/sqlalchemy/dialects/mysql/base.py @@ -370,10 +370,11 @@ collection. TIMESTAMP Columns and NULL -------------------------- -MySQL enforces that a column which specifies the TIMESTAMP datatype implicitly -includes a default value of CURRENT_TIMESTAMP, even though this is not -stated, and additionally sets the column as NOT NULL, the opposite behavior -vs. that of all other datatypes:: +MySQL historically enforces that a column which specifies the +TIMESTAMP datatype implicitly includes a default value of +CURRENT_TIMESTAMP, even though this is not stated, and additionally +sets the column as NOT NULL, the opposite behavior vs. that of all +other datatypes:: mysql> CREATE TABLE ts_test ( -> a INTEGER, @@ -400,22 +401,29 @@ with NOT NULL. But when the column is of type TIMESTAMP, an implicit default of CURRENT_TIMESTAMP is generated which also coerces the column to be a NOT NULL, even though we did not specify it as such. -Therefore, the usual "NOT NULL" clause *does not apply* to a TIMESTAMP -column; MySQL selects this implicitly. SQLAlchemy therefore does not render -NOT NULL for a TIMESTAMP column on MySQL. However, it *does* render -NULL when we specify nullable=True, or if we leave nullable absent, as it -also defaults to True. This is to accommodate the essentially -reverse behavior of the NULL flag for TIMESTAMP:: +This behavior of MySQL can be changed on the MySQL side using the +`explicit_defaults_for_timestamp +<http://dev.mysql.com/doc/refman/5.6/en/server-system-variables.html +#sysvar_explicit_defaults_for_timestamp>`_ configuration flag introduced in +MySQL 5.6. With this server setting enabled, TIMESTAMP columns behave like +any other datatype on the MySQL side with regards to defaults and nullability. - from sqlalchemy import MetaData, TIMESTAMP, Integer, Table, Column, text +However, to accommodate the vast majority of MySQL databases that do not +specify this new flag, SQLAlchemy emits the "NULL" specifier explicitly with +any TIMESTAMP column that does not specify ``nullable=False``. In order +to accommodate newer databases that specify ``explicit_defaults_for_timestamp``, +SQLAlchemy also emits NOT NULL for TIMESTAMP columns that do specify +``nullable=False``. The following example illustrates:: + + from sqlalchemy import MetaData, Integer, Table, Column, text + from sqlalchemy.dialects.mysql import TIMESTAMP m = MetaData() t = Table('ts_test', m, Column('a', Integer), Column('b', Integer, nullable=False), Column('c', TIMESTAMP), - Column('d', TIMESTAMP, nullable=False), - Column('e', TIMESTAMP, nullable=True) + Column('d', TIMESTAMP, nullable=False) ) @@ -423,35 +431,19 @@ reverse behavior of the NULL flag for TIMESTAMP:: e = create_engine("mysql://scott:tiger@localhost/test", echo=True) m.create_all(e) -In the output, we can see that the TIMESTAMP column receives a different -treatment for NULL / NOT NULL vs. that of the INTEGER:: +output:: CREATE TABLE ts_test ( a INTEGER, b INTEGER NOT NULL, c TIMESTAMP NULL, - d TIMESTAMP, - e TIMESTAMP NULL + d TIMESTAMP NOT NULL ) -MySQL above receives the NULL/NOT NULL constraint as is stated in our -original :class:`.Table`:: - - mysql> SHOW CREATE TABLE ts_test; - +---------+--------------------------- - | Table | Create Table - +---------+--------------------------- - | ts_test | CREATE TABLE `ts_test` ( - `a` int(11) DEFAULT NULL, - `b` int(11) NOT NULL, - `c` timestamp NULL DEFAULT NULL, - `d` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - `e` timestamp NULL DEFAULT NULL - ) ENGINE=MyISAM DEFAULT CHARSET=latin1 - -Be sure to always favor the ``SHOW CREATE TABLE`` output over the -SQLAlchemy-emitted DDL when checking table definitions, as MySQL's -rules can be hard to predict. +.. versionchanged:: 1.0.0 - SQLAlchemy now renders NULL or NOT NULL in all + cases for TIMESTAMP columns, to accommodate + ``explicit_defaults_for_timestamp``. Prior to this version, it will + not render "NOT NULL" for a TIMESTAMP column that is ``nullable=False``. """ @@ -1865,19 +1857,20 @@ class MySQLDDLCompiler(compiler.DDLCompiler): column.type, type_expression=column) ] - default = self.get_column_default_string(column) - if default is not None: - colspec.append('DEFAULT ' + default) - is_timestamp = isinstance(column.type, sqltypes.TIMESTAMP) - if not column.nullable and not is_timestamp: + + if not column.nullable: colspec.append('NOT NULL') # see: http://docs.sqlalchemy.org/en/latest/dialects/ # mysql.html#mysql_timestamp_null - elif column.nullable and is_timestamp and default is None: + elif column.nullable and is_timestamp: colspec.append('NULL') + default = self.get_column_default_string(column) + if default is not None: + colspec.append('DEFAULT ' + default) + if column is column.table._autoincrement_column and \ column.server_default is None: colspec.append('AUTO_INCREMENT') @@ -3007,8 +3000,7 @@ class MySQLTableDefinitionParser(object): if not spec['full']: util.warn("Incomplete reflection of column definition %r" % line) - name, type_, args, notnull = \ - spec['name'], spec['coltype'], spec['arg'], spec['notnull'] + name, type_, args = spec['name'], spec['coltype'], spec['arg'] try: col_type = self.dialect.ischema_names[type_] @@ -3033,7 +3025,6 @@ class MySQLTableDefinitionParser(object): for kw in ('charset', 'collate'): if spec.get(kw, False): type_kw[kw] = spec[kw] - if issubclass(col_type, _EnumeratedValues): type_args = _EnumeratedValues._strip_values(type_args) @@ -3042,11 +3033,12 @@ class MySQLTableDefinitionParser(object): type_instance = col_type(*type_args, **type_kw) - col_args, col_kw = [], {} + col_kw = {} # NOT NULL col_kw['nullable'] = True - if spec.get('notnull', False): + # this can be "NULL" in the case of TIMESTAMP + if spec.get('notnull', False) == 'NOT NULL': col_kw['nullable'] = False # AUTO_INCREMENT @@ -3165,7 +3157,7 @@ class MySQLTableDefinitionParser(object): r'(?: +(?P<zerofill>ZEROFILL))?' r'(?: +CHARACTER SET +(?P<charset>[\w_]+))?' r'(?: +COLLATE +(?P<collate>[\w_]+))?' - r'(?: +(?P<notnull>NOT NULL))?' + r'(?: +(?P<notnull>(?:NOT )?NULL))?' r'(?: +DEFAULT +(?P<default>' r'(?:NULL|\x27(?:\x27\x27|[^\x27])*\x27|\w+' r'(?: +ON UPDATE \w+)?)' @@ -3185,7 +3177,7 @@ class MySQLTableDefinitionParser(object): r'%(iq)s(?P<name>(?:%(esc_fq)s|[^%(fq)s])+)%(fq)s +' r'(?P<coltype>\w+)' r'(?:\((?P<arg>(?:\d+|\d+,\d+|\x27(?:\x27\x27|[^\x27])+\x27))\))?' - r'.*?(?P<notnull>NOT NULL)?' + r'.*?(?P<notnull>(?:NOT )NULL)?' % quotes ) diff --git a/test/dialect/mysql/test_reflection.py b/test/dialect/mysql/test_reflection.py index 99733e397..957a7eb21 100644 --- a/test/dialect/mysql/test_reflection.py +++ b/test/dialect/mysql/test_reflection.py @@ -7,6 +7,7 @@ from sqlalchemy.dialects.mysql import base as mysql from sqlalchemy.testing import fixtures, AssertsExecutionResults from sqlalchemy import testing + class ReflectionTest(fixtures.TestBase, AssertsExecutionResults): __only_on__ = 'mysql' @@ -23,13 +24,12 @@ class ReflectionTest(fixtures.TestBase, AssertsExecutionResults): DefaultClause(''), nullable=False), Column('c2', String(10), DefaultClause('0')), Column('c3', String(10), DefaultClause('abc')), - Column('c4', TIMESTAMP, DefaultClause('2009-04-05 12:00:00' - )), + Column('c4', TIMESTAMP, DefaultClause('2009-04-05 12:00:00')), Column('c5', TIMESTAMP), Column('c6', TIMESTAMP, DefaultClause(sql.text("CURRENT_TIMESTAMP " "ON UPDATE CURRENT_TIMESTAMP"))), - ) + ) def_table.create() try: reflected = Table('mysql_def', MetaData(testing.db), @@ -284,6 +284,55 @@ class ReflectionTest(fixtures.TestBase, AssertsExecutionResults): self.assert_('TABLES' in view_names) @testing.provide_metadata + def test_nullable_reflection(self): + """test reflection of NULL/NOT NULL, in particular with TIMESTAMP + defaults where MySQL is inconsistent in how it reports CREATE TABLE. + + """ + meta = self.metadata + Table('nn_t', meta) + testing.db.execute(""" + CREATE TABLE nn_t ( + x INTEGER NULL, + y INTEGER NOT NULL, + z INTEGER, + q TIMESTAMP NULL, + p TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + r TIMESTAMP NOT NULL, + s TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + t TIMESTAMP, + u TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + eq_( + [ + { + "name": d['name'], + "nullable": d['nullable'], + "default": d['default'], + } + for d in + inspect(testing.db).get_columns('nn_t') + ], + [ + {'name': 'x', 'nullable': True, 'default': None}, + {'name': 'y', 'nullable': False, 'default': None}, + {'name': 'z', 'nullable': True, 'default': None}, + {'name': 'q', 'nullable': True, 'default': None}, + {'name': 'p', 'nullable': True, + 'default': 'CURRENT_TIMESTAMP'}, + {'name': 'r', 'nullable': False, + 'default': "'0000-00-00 00:00:00'"}, + {'name': 's', 'nullable': False, + 'default': 'CURRENT_TIMESTAMP'}, + {'name': 't', 'nullable': False, + 'default': "'0000-00-00 00:00:00'"}, + {'name': 'u', 'nullable': False, + 'default': 'CURRENT_TIMESTAMP'}, + ] + ) + + @testing.provide_metadata def test_reflection_with_unique_constraint(self): insp = inspect(testing.db) diff --git a/test/dialect/mysql/test_types.py b/test/dialect/mysql/test_types.py index 13425dc10..7c279ffbf 100644 --- a/test/dialect/mysql/test_types.py +++ b/test/dialect/mysql/test_types.py @@ -11,6 +11,7 @@ from sqlalchemy import testing import datetime import decimal + class TypesTest(fixtures.TestBase, AssertsExecutionResults, AssertsCompiledSQL): "Test MySQL column types" @@ -416,29 +417,66 @@ class TypesTest(fixtures.TestBase, AssertsExecutionResults, AssertsCompiledSQL): """Exercise funky TIMESTAMP default syntax when used in columns.""" columns = [ - ([TIMESTAMP], + ([TIMESTAMP], {}, 'TIMESTAMP NULL'), - ([mysql.MSTimeStamp], + + ([mysql.MSTimeStamp], {}, 'TIMESTAMP NULL'), + + ([mysql.MSTimeStamp(), + DefaultClause(sql.text('CURRENT_TIMESTAMP'))], + {}, + "TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"), + ([mysql.MSTimeStamp, DefaultClause(sql.text('CURRENT_TIMESTAMP'))], - "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"), + {'nullable': False}, + "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"), + ([mysql.MSTimeStamp, DefaultClause(sql.text("'1999-09-09 09:09:09'"))], - "TIMESTAMP DEFAULT '1999-09-09 09:09:09'"), + {'nullable': False}, + "TIMESTAMP NOT NULL DEFAULT '1999-09-09 09:09:09'"), + + ([mysql.MSTimeStamp(), + DefaultClause(sql.text("'1999-09-09 09:09:09'"))], + {}, + "TIMESTAMP NULL DEFAULT '1999-09-09 09:09:09'"), + + ([mysql.MSTimeStamp(), + DefaultClause(sql.text( + "'1999-09-09 09:09:09' " + "ON UPDATE CURRENT_TIMESTAMP"))], + {}, + "TIMESTAMP NULL DEFAULT '1999-09-09 09:09:09' " + "ON UPDATE CURRENT_TIMESTAMP"), + ([mysql.MSTimeStamp, - DefaultClause(sql.text("'1999-09-09 09:09:09' " - "ON UPDATE CURRENT_TIMESTAMP"))], - "TIMESTAMP DEFAULT '1999-09-09 09:09:09' " + DefaultClause(sql.text( + "'1999-09-09 09:09:09' " + "ON UPDATE CURRENT_TIMESTAMP"))], + {'nullable': False}, + "TIMESTAMP NOT NULL DEFAULT '1999-09-09 09:09:09' " + "ON UPDATE CURRENT_TIMESTAMP"), + + ([mysql.MSTimeStamp(), + DefaultClause(sql.text( + "CURRENT_TIMESTAMP " + "ON UPDATE CURRENT_TIMESTAMP"))], + {}, + "TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP " "ON UPDATE CURRENT_TIMESTAMP"), + ([mysql.MSTimeStamp, - DefaultClause(sql.text("CURRENT_TIMESTAMP " - "ON UPDATE CURRENT_TIMESTAMP"))], - "TIMESTAMP DEFAULT CURRENT_TIMESTAMP " + DefaultClause(sql.text( + "CURRENT_TIMESTAMP " + "ON UPDATE CURRENT_TIMESTAMP"))], + {'nullable': False}, + "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP " "ON UPDATE CURRENT_TIMESTAMP"), - ] - for spec, expected in columns: - c = Column('t', *spec) + ] + for spec, kw, expected in columns: + c = Column('t', *spec, **kw) Table('t', MetaData(), c) self.assert_compile( schema.CreateColumn(c), @@ -448,19 +486,20 @@ class TypesTest(fixtures.TestBase, AssertsExecutionResults, AssertsCompiledSQL): @testing.provide_metadata def test_timestamp_nullable(self): - ts_table = Table('mysql_timestamp', self.metadata, - Column('t1', TIMESTAMP), - Column('t2', TIMESTAMP, nullable=False), - ) + ts_table = Table( + 'mysql_timestamp', self.metadata, + Column('t1', TIMESTAMP), + Column('t2', TIMESTAMP, nullable=False), + mysql_engine='InnoDB' + ) self.metadata.create_all() - now = testing.db.execute("select now()").scalar() - # TIMESTAMP without NULL inserts current time when passed # NULL. when not passed, generates 0000-00-00 quite # annoyingly. - ts_table.insert().execute({'t1': now, 't2': None}) - ts_table.insert().execute({'t1': None, 't2': None}) + # the flag http://dev.mysql.com/doc/refman/5.6/en/\ + # server-system-variables.html#sysvar_explicit_defaults_for_timestamp + # changes this for 5.6 if set. # normalize dates that are over the second boundary def normalize(dt): @@ -470,11 +509,27 @@ class TypesTest(fixtures.TestBase, AssertsExecutionResults, AssertsCompiledSQL): return now else: return dt - eq_( - [tuple([normalize(dt) for dt in row]) - for row in ts_table.select().execute()], - [(now, now), (None, now)] - ) + + with testing.db.begin() as conn: + now = conn.scalar("select now()") + + conn.execute( + ts_table.insert(), {'t1': now, 't2': None}) + conn.execute( + ts_table.insert(), {'t1': None, 't2': None}) + conn.execute( + ts_table.insert(), {'t2': None}) + + eq_( + [tuple([normalize(dt) for dt in row]) + for row in conn.execute(ts_table.select())], + [ + (now, now), + (None, now), + (None, now) + ] + ) + def test_datetime_generic(self): self.assert_compile( |