From 7eafe744c2b3961b303c9bb1f7f219eeb8738840 Mon Sep 17 00:00:00 2001 From: iElectric Date: Tue, 16 Jun 2009 15:17:33 +0000 Subject: [PATCH] - refactor migrate.changeset; - visitors are refactored to be more unified - constraint module is refactored, CheckConstraint is added - documentation is partialy updated, dialect support table is added (unfinished) - test_constraint was updated NOTE: oracle and mysql were not tested, *may be broken* --- TODO | 3 + docs/api.rst | 17 ++- docs/changelog.rst | 7 + docs/changeset.rst | 76 +++++++++-- docs/conf.py | 5 +- docs/index.rst | 43 +++++- migrate/changeset/__init__.py | 6 + migrate/changeset/ansisql.py | 157 ++++++++++------------ migrate/changeset/constraint.py | 126 ++++++++++++------ migrate/changeset/databases/mysql.py | 8 -- migrate/changeset/databases/sqlite.py | 51 ++++--- migrate/changeset/databases/visitor.py | 11 +- migrate/changeset/schema.py | 74 ++++------- test/changeset/test_changeset.py | 36 ++--- test/changeset/test_constraint.py | 176 ++++++++++++++++++------- 15 files changed, 509 insertions(+), 287 deletions(-) diff --git a/TODO b/TODO index 922778b..73c3f83 100644 --- a/TODO +++ b/TODO @@ -12,3 +12,6 @@ make_update_script_for_model: - refactor test_shell to test_api and use TestScript for cmd line testing - controlledschema.drop() drops whole migrate table, maybe there are some other repositories bound to it! + +- document sqlite hacks (unique index for pk constraint) +- document constraints usage, document all ways then can be used, document cascade,table,columns options diff --git a/docs/api.rst b/docs/api.rst index d20c0d3..1edd4eb 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -10,6 +10,7 @@ Module :mod:`ansisql ` .. automodule:: migrate.changeset.ansisql :members: + :member-order: groupwise :synopsis: Standard SQL implementation for altering database schemas Module :mod:`constraint ` @@ -17,6 +18,8 @@ Module :mod:`constraint ` .. automodule:: migrate.changeset.constraint :members: + :show-inheritance: + :member-order: groupwise :synopsis: Standalone schema constraint objects Module :mod:`databases ` @@ -26,20 +29,28 @@ Module :mod:`databases ` :members: :synopsis: Database specific changeset implementations +.. _mysql-d: + Module :mod:`mysql ` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + .. automodule:: migrate.changeset.databases.mysql :members: :synopsis: MySQL database specific changeset implementations +.. _oracle-d: + Module :mod:`oracle ` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + .. automodule:: migrate.changeset.databases.oracle :members: :synopsis: Oracle database specific changeset implementations +.. _postgres-d: + Module :mod:`postgres ` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -47,8 +58,10 @@ Module :mod:`postgres ` :members: :synopsis: PostgreSQL database specific changeset implementations -Module :mod:`sqlite ` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. _sqlite-d: + +Module :mod:`sqlite ` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. automodule:: migrate.changeset.databases.sqlite :members: diff --git a/docs/changelog.rst b/docs/changelog.rst index af80533..f4e12d5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,13 @@ 0.5.5 ----- +- code coverage is up to 99% +- Constraint classes have cascade=True keyword argument to issue CASCADE drop where supported +- added UniqueConstraint/CheckConstraint and corresponding create/drop methods +- partial refactoring of changeset package +- majoy update to documentation +- dialect support table was added to documentation + .. _backwards-055: **Backward incompatible changes**: diff --git a/docs/changeset.rst b/docs/changeset.rst index d673be0..133a0b1 100644 --- a/docs/changeset.rst +++ b/docs/changeset.rst @@ -1,4 +1,5 @@ .. _changeset-system: +.. highlight:: python ****************** Database changeset @@ -73,6 +74,7 @@ Rename a table:: table.rename('newtablename') .. _`table create/drop`: http://www.sqlalchemy.org/docs/05/metadata.html#creating-and-dropping-database-tables +.. currentmodule:: migrate.changeset.constraint Index ===== @@ -88,28 +90,86 @@ Rename an index, given an SQLAlchemy ``Index`` object:: Constraint ========== -SQLAlchemy supports creating/dropping constraints at the same time a table is created/dropped. SQLAlchemy Migrate adds support for creating/dropping primary/foreign key constraints independently. +SQLAlchemy supports creating/dropping constraints at the same time a table is created/dropped. SQLAlchemy Migrate adds support for creating/dropping :class:`PrimaryKeyConstraint`/:class:`ForeignKeyConstraint`/:class:`CheckConstraint`/:class:`UniqueConstraint` constraints independently. (as ALTER TABLE statements). + +The following rundowns are true for all constraints classes: + +1. Make sure you do ``from migrate.changeset import *`` after SQLAlchemy imports since `migrate` does not patch SA's Constraints. + +2. You can also use Constraints as in SQLAlchemy. In this case passing table argument explicitly is required:: + + cons = PrimaryKeyConstraint('id', 'num', table=self.table) + + # Create the constraint + cons.create() + + # Drop the constraint + cons.drop() + +or you can pass column objects (and table argument can be left out). + +3. Some dialects support CASCADE option when dropping constraints:: + + cons = PrimaryKeyConstraint(col1, col2) + + # Create the constraint + cons.create() + + # Drop the constraint + cons.drop(cascade=True) + + +.. note:: + SQLAlchemy Migrate will try to guess the name of the constraints for databases, but if it's something other than the default, you'll need to give its name. Best practice is to always name your constraints. Note that Oracle requires that you state the name of the constraint to be created/dropped. + + +Examples +--------- Primary key constraints:: + from migrate.changeset import * + cons = PrimaryKeyConstraint(col1, col2) + # Create the constraint cons.create() + # Drop the constraint cons.drop() -Note that Oracle requires that you state the name of the primary key constraint to be created/dropped. SQLAlchemy Migrate will try to guess the name of the PK constraint for other databases, but if it's something other than the default, you'll need to give its name:: - - PrimaryKeyConstraint(col1, col2, name='my_pk_constraint') - Foreign key constraints:: + from migrate.changeset import * + cons = ForeignKeyConstraint([table.c.fkey], [othertable.c.id]) + # Create the constraint cons.create() + # Drop the constraint cons.drop() -Names are specified just as with primary key constraints:: - - ForeignKeyConstraint([table.c.fkey], [othertable.c.id], name='my_fk_constraint') +Check constraints:: + + from migrate.changeset import * + + cons = CheckConstraint('id > 3', columns=[table.c.id]) + + # Create the constraint + cons.create() + + # Drop the constraint + cons.drop() + +Unique constraints:: + + from migrate.changeset import * + + cons = UniqueConstraint('id', 'age', table=self.table) + + # Create the constraint + cons.create() + + # Drop the constraint + cons.drop() diff --git a/docs/conf.py b/docs/conf.py index 42d3652..20d48c8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,10 @@ sys.path.append(os.path.dirname(os.path.abspath('.'))) # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] + +# link to sqlalchemy docs +intersphinx_mapping = {'http://www.sqlalchemy.org/docs/05/': None} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/index.rst b/docs/index.rst index 36c83da..9e1d96c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,13 +31,52 @@ Version **0.5.5** breaks backward compatability, please read :ref:`changelog ` for more info. -Download and Development of SQLAlchemy Migrate ----------------------------------------------- + +Download and Development +------------------------ .. toctree:: download + +Dialect support +---------------------------------- + ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| Operation / Dialect | :ref:`sqlite ` | :ref:`postgres ` | :ref:`mysql ` | :ref:`oracle ` | firebird | mssql | +| | | | | | | | ++==========================+==========================+==============================+========================+===========================+==========+=======+ +| ALTER TABLE | yes | yes | | | | | +| RENAME TABLE | | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| ALTER TABLE | yes | yes | | | | | +| RENAME COLUMN | (workaround) [#1]_ | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| ALTER TABLE | yes | yes | | | | | +| DROP COLUMN | (workaround) [#1]_ | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| ALTER TABLE | yes | yes | | | | | +| ADD COLUMN | (with limitations) [#2]_ | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| ALTER TABLE | no | yes | | | | | +| ADD CONSTRAINT | | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| ALTER TABLE | no | yes | | | | | +| DROP CONSTRAINT | | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| ALTER TABLE | no | yes | | | | | +| ALTER COLUMN | | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ +| RENAME INDEX | no | yes | | | | | +| | | | | | | | ++--------------------------+--------------------------+------------------------------+------------------------+---------------------------+----------+-------+ + + +.. [#1] Table is renamed to temporary table, new table is created followed by INSERT statements. +.. [#2] Visit http://www.sqlite.org/lang_altertable.html for more information. + + Documentation ------------- diff --git a/migrate/changeset/__init__.py b/migrate/changeset/__init__.py index a9d1772..d93282f 100644 --- a/migrate/changeset/__init__.py +++ b/migrate/changeset/__init__.py @@ -4,5 +4,11 @@ .. [#] SQL Data Definition Language """ +import sqlalchemy + from migrate.changeset.schema import * from migrate.changeset.constraint import * + +sqlalchemy.schema.Table.__bases__ += (ChangesetTable, ) +sqlalchemy.schema.Column.__bases__ += (ChangesetColumn, ) +sqlalchemy.schema.Index.__bases__ += (ChangesetIndex, ) diff --git a/migrate/changeset/ansisql.py b/migrate/changeset/ansisql.py index 92ed8b7..7762031 100644 --- a/migrate/changeset/ansisql.py +++ b/migrate/changeset/ansisql.py @@ -5,10 +5,15 @@ things that just happen to work with multiple databases. """ import sqlalchemy as sa -from sqlalchemy.engine.base import Connection, Dialect -from sqlalchemy.sql.compiler import SchemaGenerator -from sqlalchemy.schema import ForeignKeyConstraint -from migrate.changeset import constraint, exceptions +from sqlalchemy.engine.default import DefaultDialect +from sqlalchemy.sql.compiler import SchemaGenerator, SchemaDropper +from sqlalchemy.schema import (ForeignKeyConstraint, + PrimaryKeyConstraint, + CheckConstraint, + UniqueConstraint) + +from migrate.changeset import exceptions, constraint + SchemaIterator = sa.engine.SchemaIterator @@ -78,6 +83,14 @@ class ANSIColumnGenerator(AlterTableVisitor, SchemaGenerator): self.append(colspec) self.execute() + # add in foreign keys + if column.foreign_keys: + self.visit_alter_foriegn_keys(column) + + def visit_alter_foriegn_keys(self, column): + for fk in column.foreign_keys: + self.define_foreign_key(fk.constraint) + def visit_table(self, table): """Default table visitor, does nothing. @@ -87,7 +100,8 @@ class ANSIColumnGenerator(AlterTableVisitor, SchemaGenerator): pass -class ANSIColumnDropper(AlterTableVisitor): + +class ANSIColumnDropper(AlterTableVisitor, SchemaDropper): """Extends ANSI SQL dropper for column dropping (``ALTER TABLE DROP COLUMN``). """ @@ -118,24 +132,23 @@ class ANSISchemaChanger(AlterTableVisitor, SchemaGenerator): name. NONE means the name is unchanged. """ - def visit_table(self, param): + def visit_table(self, table): """Rename a table. Other ops aren't supported.""" - table, newname = param self.start_alter_table(table) - self.append("RENAME TO %s" % self.preparer.quote(newname, table.quote)) + self.append("RENAME TO %s" % self.preparer.quote(table.new_name, table.quote)) self.execute() - def visit_index(self, param): + def visit_index(self, index): """Rename an index""" - index, newname = param self.append("ALTER INDEX %s RENAME TO %s" % (self.preparer.quote(self._validate_identifier(index.name, True), index.quote), - self.preparer.quote(self._validate_identifier(newname, True) , index.quote))) + self.preparer.quote(self._validate_identifier(index.new_name, True) , index.quote))) self.execute() - def visit_column(self, delta): + def visit_column(self, column): """Rename/change a column.""" # ALTER COLUMN is implemented as several ALTER statements + delta = column.delta keys = delta.keys() if 'type' in keys: self._run_subvisit(delta, self._visit_column_type) @@ -246,99 +259,73 @@ class ANSIConstraintCommon(AlterTableVisitor): ret = cons.name = cons.autoname() return self.preparer.quote(ret, cons.quote) + def visit_migrate_primary_key_constraint(self, *p, **k): + self._visit_constraint(*p, **k) -class ANSIConstraintGenerator(ANSIConstraintCommon): + def visit_migrate_foreign_key_constraint(self, *p, **k): + self._visit_constraint(*p, **k) + + def visit_migrate_check_constraint(self, *p, **k): + self._visit_constraint(*p, **k) + + def visit_migrate_unique_constraint(self, *p, **k): + self._visit_constraint(*p, **k) + + +class ANSIConstraintGenerator(ANSIConstraintCommon, SchemaGenerator): def get_constraint_specification(self, cons, **kwargs): - if isinstance(cons, constraint.PrimaryKeyConstraint): - col_names = ', '.join([self.preparer.format_column(col) for col in cons.columns]) - ret = "PRIMARY KEY (%s)" % col_names - if cons.name: - # Named constraint - ret = ("CONSTRAINT %s " % self.preparer.format_constraint(cons)) + ret - elif isinstance(cons, constraint.ForeignKeyConstraint): - params = dict( - columns = ', '.join(map(self.preparer.format_column, cons.columns)), - reftable = self.preparer.format_table(cons.reftable), - referenced = ', '.join(map(self.preparer.format_column, cons.referenced)), - name = self.get_constraint_name(cons), - ) - ret = "CONSTRAINT %(name)s FOREIGN KEY (%(columns)s) "\ - "REFERENCES %(reftable)s (%(referenced)s)" % params - if cons.onupdate: - ret = ret + " ON UPDATE %s" % cons.onupdate - if cons.ondelete: - ret = ret + " ON DELETE %s" % cons.ondelete - elif isinstance(cons, constraint.CheckConstraint): - ret = "CHECK (%s)" % cons.sqltext + """Constaint SQL generators. + + We cannot use SA visitors because they append comma. + """ + if isinstance(cons, PrimaryKeyConstraint): + if cons.name is not None: + self.append("CONSTRAINT %s " % self.preparer.format_constraint(cons)) + self.append("PRIMARY KEY ") + self.append("(%s)" % ', '.join(self.preparer.quote(c.name, c.quote) + for c in cons)) + self.define_constraint_deferrability(cons) + elif isinstance(cons, ForeignKeyConstraint): + self.define_foreign_key(cons) + elif isinstance(cons, CheckConstraint): + if cons.name is not None: + self.append("CONSTRAINT %s " % + self.preparer.format_constraint(cons)) + self.append(" CHECK (%s)" % cons.sqltext) + self.define_constraint_deferrability(cons) + elif isinstance(cons, UniqueConstraint): + if cons.name is not None: + self.append("CONSTRAINT %s " % + self.preparer.format_constraint(cons)) + self.append(" UNIQUE (%s)" % \ + (', '.join(self.preparer.quote(c.name, c.quote) for c in cons))) + self.define_constraint_deferrability(cons) else: raise exceptions.InvalidConstraintError(cons) - return ret def _visit_constraint(self, constraint): table = self.start_alter_table(constraint) + constraint.name = self.get_constraint_name(constraint) self.append("ADD ") - spec = self.get_constraint_specification(constraint) - self.append(spec) + self.get_constraint_specification(constraint) self.execute() - def visit_migrate_primary_key_constraint(self, *p, **k): - return self._visit_constraint(*p, **k) - def visit_migrate_foreign_key_constraint(self, *p, **k): - return self._visit_constraint(*p, **k) - - def visit_migrate_check_constraint(self, *p, **k): - return self._visit_constraint(*p, **k) - - -class ANSIConstraintDropper(ANSIConstraintCommon): +class ANSIConstraintDropper(ANSIConstraintCommon, SchemaDropper): def _visit_constraint(self, constraint): self.start_alter_table(constraint) self.append("DROP CONSTRAINT ") self.append(self.get_constraint_name(constraint)) + if constraint.cascade: + self.append(" CASCADE") self.execute() - def visit_migrate_primary_key_constraint(self, *p, **k): - return self._visit_constraint(*p, **k) - def visit_migrate_foreign_key_constraint(self, *p, **k): - return self._visit_constraint(*p, **k) - - def visit_migrate_check_constraint(self, *p, **k): - return self._visit_constraint(*p, **k) - - -class ANSIFKGenerator(AlterTableVisitor, SchemaGenerator): - """Extends ansisql generator for column creation (alter table add col)""" - - def __init__(self, *args, **kwargs): - self.fk = kwargs.pop('fk', None) - super(ANSIFKGenerator, self).__init__(*args, **kwargs) - - def visit_column(self, column): - """Create foreign keys for a column (table already exists); #32""" - - if self.fk: - self.add_foreignkey(self.fk.constraint) - - if self.buffer.getvalue() != '': - self.execute() - - def visit_table(self, table): - pass - - -class ANSIDialect(object): +class ANSIDialect(DefaultDialect): columngenerator = ANSIColumnGenerator columndropper = ANSIColumnDropper schemachanger = ANSISchemaChanger - columnfkgenerator = ANSIFKGenerator - - @classmethod - def visitor(self, name): - return getattr(self, name) - - def reflectconstraints(self, connection, table_name): - raise NotImplementedError() + constraintgenerator = ANSIConstraintGenerator + constraintdropper = ANSIConstraintDropper diff --git a/migrate/changeset/constraint.py b/migrate/changeset/constraint.py index 21058b6..f8e6871 100644 --- a/migrate/changeset/constraint.py +++ b/migrate/changeset/constraint.py @@ -4,6 +4,8 @@ import sqlalchemy from sqlalchemy import schema +from migrate.changeset.exceptions import * + class ConstraintChangeset(object): """Base class for Constraint classes.""" @@ -24,55 +26,50 @@ class ConstraintChangeset(object): colnames.append(col) return colnames, table - def create(self, *args, **kwargs): + def __do_imports(self, visitor_name, *a, **kw): + engine = kw.pop('engine', self.table.bind) + from migrate.changeset.databases.visitor import (get_engine_visitor, + run_single_visitor) + visitorcallable = get_engine_visitor(engine, visitor_name) + run_single_visitor(engine, visitorcallable, self, *a, **kw) + + def create(self, *a, **kw): """Create the constraint in the database. :param engine: the database engine to use. If this is \ :keyword:`None` the instance's engine will be used :type engine: :class:`sqlalchemy.engine.base.Engine` """ - from migrate.changeset.databases.visitor import get_engine_visitor - visitorcallable = get_engine_visitor(self.table.bind, - 'constraintgenerator') - _engine_run_visitor(self.table.bind, visitorcallable, self) + self.__do_imports('constraintgenerator', *a, **kw) - def drop(self, *args, **kwargs): + def drop(self, *a, **kw): """Drop the constraint from the database. :param engine: the database engine to use. If this is :keyword:`None` the instance's engine will be used + :param cascade: Issue CASCADE drop if database supports it :type engine: :class:`sqlalchemy.engine.base.Engine` + :type cascade: bool + :returns: Instance with cleared columns """ - from migrate.changeset.databases.visitor import get_engine_visitor - visitorcallable = get_engine_visitor(self.table.bind, - 'constraintdropper') - _engine_run_visitor(self.table.bind, visitorcallable, self) + self.cascade = kw.pop('cascade', False) + self.__do_imports('constraintdropper', *a, **kw) self.columns.clear() return self - def accept_schema_visitor(self, visitor, *p, **k): - """Call the visitor only if it defines the given function""" - return getattr(visitor, self._func)(self) - - def autoname(self): - """Automatically generate a name for the constraint instance. - - Subclasses must implement this method. - """ - - -def _engine_run_visitor(engine, visitorcallable, element, **kwargs): - conn = engine.connect() - try: - element.accept_schema_visitor(visitorcallable(conn)) - finally: - conn.close() - class PrimaryKeyConstraint(ConstraintChangeset, schema.PrimaryKeyConstraint): - """Primary key constraint class.""" + """Construct PrimaryKeyConstraint + + Migrate's additional parameters: - _func = 'visit_migrate_primary_key_constraint' + :param cols: Columns in constraint. + :param table: If columns are passed as strings, this kw is required + :type table: Table instance + :type cols: strings or Column instances + """ + + __visit_name__ = 'migrate_primary_key_constraint' def __init__(self, *cols, **kwargs): colnames, table = self._normalize_columns(cols) @@ -81,23 +78,34 @@ class PrimaryKeyConstraint(ConstraintChangeset, schema.PrimaryKeyConstraint): if table is not None: self._set_parent(table) + def autoname(self): """Mimic the database's automatic constraint names""" return "%s_pkey" % self.table.name class ForeignKeyConstraint(ConstraintChangeset, schema.ForeignKeyConstraint): - """Foreign key constraint class.""" + """Construct ForeignKeyConstraint + + Migrate's additional parameters: - _func = 'visit_migrate_foreign_key_constraint' + :param columns: Columns in constraint + :param refcolumns: Columns that this FK reffers to in another table. + :param table: If columns are passed as strings, this kw is required + :type table: Table instance + :type columns: list of strings or Column instances + :type refcolumns: list of strings or Column instances + """ - def __init__(self, columns, refcolumns, *p, **k): + __visit_name__ = 'migrate_foreign_key_constraint' + + def __init__(self, columns, refcolumns, *args, **kwargs): colnames, table = self._normalize_columns(columns) - table = k.pop('table', table) + table = kwargs.pop('table', table) refcolnames, reftable = self._normalize_columns(refcolumns, table_name=True) - super(ForeignKeyConstraint, self).__init__(colnames, refcolnames, *p, - **k) + super(ForeignKeyConstraint, self).__init__(colnames, refcolnames, *args, + **kwargs) if table is not None: self._set_parent(table) @@ -118,20 +126,60 @@ class ForeignKeyConstraint(ConstraintChangeset, schema.ForeignKeyConstraint): class CheckConstraint(ConstraintChangeset, schema.CheckConstraint): - """Check constraint class.""" + """Construct CheckConstraint - _func = 'visit_migrate_check_constraint' + Migrate's additional parameters: + + :param sqltext: Plain SQL text to check condition + :param columns: If not name is applied, you must supply this kw\ + to autoname constraint + :param table: If columns are passed as strings, this kw is required + :type table: Table instance + :type columns: list of Columns instances + :type sqltext: string + """ + + __visit_name__ = 'migrate_check_constraint' def __init__(self, sqltext, *args, **kwargs): - cols = kwargs.pop('columns') + cols = kwargs.pop('columns', False) + if not cols and not kwargs.get('name', False): + raise InvalidConstraintError('You must either set "name"' + 'parameter or "columns" to autogenarate it.') colnames, table = self._normalize_columns(cols) table = kwargs.pop('table', table) ConstraintChangeset.__init__(self, *args, **kwargs) schema.CheckConstraint.__init__(self, sqltext, *args, **kwargs) if table is not None: + self.table = table self._set_parent(table) self.colnames = colnames def autoname(self): return "%(table)s_%(cols)s_check" % \ dict(table=self.table.name, cols="_".join(self.colnames)) + + +class UniqueConstraint(ConstraintChangeset, schema.UniqueConstraint): + """Construct UniqueConstraint + + Migrate's additional parameters: + + :param cols: Columns in constraint. + :param table: If columns are passed as strings, this kw is required + :type table: Table instance + :type cols: strings or Column instances + """ + + __visit_name__ = 'migrate_unique_constraint' + + def __init__(self, *cols, **kwargs): + self.colnames, table = self._normalize_columns(cols) + table = kwargs.pop('table', table) + super(UniqueConstraint, self).__init__(*self.colnames, **kwargs) + if table is not None: + self._set_parent(table) + + def autoname(self): + """Mimic the database's automatic constraint names""" + return "%s_%s_key" % (self.table.name, self.colnames[0]) diff --git a/migrate/changeset/databases/mysql.py b/migrate/changeset/databases/mysql.py index 468bbcb..fc65569 100644 --- a/migrate/changeset/databases/mysql.py +++ b/migrate/changeset/databases/mysql.py @@ -53,18 +53,11 @@ class MySQLSchemaChanger(MySQLSchemaGenerator, ansisql.ANSISchemaChanger): # If MySQL can do this, I can't find how raise exceptions.NotSupportedError("MySQL cannot rename indexes") - class MySQLConstraintGenerator(ansisql.ANSIConstraintGenerator): pass class MySQLConstraintDropper(ansisql.ANSIConstraintDropper): - #def visit_constraint(self,constraint): - # if isinstance(constraint,sqlalchemy.schema.PrimaryKeyConstraint): - # return self._visit_constraint_pk(constraint) - # elif isinstance(constraint,sqlalchemy.schema.ForeignKeyConstraint): - # return self._visit_constraint_fk(constraint) - # return super(MySQLConstraintDropper,self).visit_constraint(constraint) def visit_migrate_primary_key_constraint(self, constraint): self.start_alter_table(constraint) @@ -77,7 +70,6 @@ class MySQLConstraintDropper(ansisql.ANSIConstraintDropper): self.append(self.preparer.format_constraint(constraint)) self.execute() - class MySQLDialect(ansisql.ANSIDialect): columngenerator = MySQLColumnGenerator columndropper = MySQLColumnDropper diff --git a/migrate/changeset/databases/sqlite.py b/migrate/changeset/databases/sqlite.py index 28b2dc7..94ac940 100644 --- a/migrate/changeset/databases/sqlite.py +++ b/migrate/changeset/databases/sqlite.py @@ -3,27 +3,33 @@ .. _`SQLite`: http://www.sqlite.org/ """ -from migrate.changeset import ansisql, constraint, exceptions +from migrate.changeset import ansisql, exceptions, constraint from sqlalchemy.databases import sqlite as sa_base from sqlalchemy import Table, MetaData #import sqlalchemy as sa SQLiteSchemaGenerator = sa_base.SQLiteSchemaGenerator +class SQLiteCommon(object): -class SQLiteHelper(object): + def _not_supported(self, op): + raise exceptions.NotSupportedError("SQLite does not support " + "%s; see http://www.sqlite.org/lang_altertable.html" % op) - def visit_column(self, param): + +class SQLiteHelper(SQLiteCommon): + + def visit_column(self, column): try: - table = self._to_table(param.table) + table = self._to_table(column.table) except: - table = self._to_table(param) + table = self._to_table(column) raise table_name = self.preparer.format_table(table) self.append('ALTER TABLE %s RENAME TO migration_tmp' % table_name) self.execute() - insertion_string = self._modify_table(table, param) + insertion_string = self._modify_table(table, column) table.create() self.append(insertion_string % {'table_name': table_name}) @@ -32,12 +38,17 @@ class SQLiteHelper(object): self.execute() -class SQLiteColumnGenerator(SQLiteSchemaGenerator, +class SQLiteColumnGenerator(SQLiteSchemaGenerator, SQLiteCommon, ansisql.ANSIColumnGenerator): - pass + """SQLite ColumnGenerator""" + + def visit_alter_foriegn_keys(self, column): + """Does not support ALTER TABLE ADD FOREIGN KEY""" + self._not_supported("ALTER TABLE ADD CONSTRAINT") class SQLiteColumnDropper(SQLiteHelper, ansisql.ANSIColumnDropper): + """SQLite ColumnDropper""" def _modify_table(self, table, column): del table.columns[column.name] @@ -47,18 +58,17 @@ class SQLiteColumnDropper(SQLiteHelper, ansisql.ANSIColumnDropper): class SQLiteSchemaChanger(SQLiteHelper, ansisql.ANSISchemaChanger): + """SQLite SchemaChanger""" - def _not_supported(self, op): - raise exceptions.NotSupportedError("SQLite does not support " - "%s; see http://www.sqlite.org/lang_altertable.html" % op) - - def _modify_table(self, table, delta): + def _modify_table(self, table, column): + delta = column.delta column = table.columns[delta.current_name] for k, v in delta.items(): setattr(column, k, v) return 'INSERT INTO %(table_name)s SELECT * from migration_tmp' - def visit_index(self, param): + def visit_index(self, index): + """Does not support ALTER INDEX""" self._not_supported('ALTER INDEX') @@ -74,17 +84,6 @@ class SQLiteConstraintGenerator(ansisql.ANSIConstraintGenerator): self.execute() -class SQLiteFKGenerator(SQLiteSchemaChanger, ansisql.ANSIFKGenerator): - def visit_column(self, column): - """Create foreign keys for a column (table already exists); #32""" - - if self.fk: - self._not_supported("ALTER TABLE ADD FOREIGN KEY") - - if self.buffer.getvalue() != '': - self.execute() - - class SQLiteConstraintDropper(ansisql.ANSIColumnDropper, ansisql.ANSIConstraintCommon): def visit_migrate_primary_key_constraint(self, constraint): @@ -94,6 +93,7 @@ class SQLiteConstraintDropper(ansisql.ANSIColumnDropper, ansisql.ANSIConstraintC self.append(msg) self.execute() +# TODO: add not_supported tags for constraint dropper/generator class SQLiteDialect(ansisql.ANSIDialect): columngenerator = SQLiteColumnGenerator @@ -101,4 +101,3 @@ class SQLiteDialect(ansisql.ANSIDialect): schemachanger = SQLiteSchemaChanger constraintgenerator = SQLiteConstraintGenerator constraintdropper = SQLiteConstraintDropper - columnfkgenerator = SQLiteFKGenerator diff --git a/migrate/changeset/databases/visitor.py b/migrate/changeset/databases/visitor.py index 4afad77..f0fa973 100644 --- a/migrate/changeset/databases/visitor.py +++ b/migrate/changeset/databases/visitor.py @@ -42,9 +42,18 @@ def get_dialect_visitor(sa_dialect, name): # map sa dialect to migrate dialect and return visitor sa_dialect_cls = sa_dialect.__class__ migrate_dialect_cls = dialects[sa_dialect_cls] - visitor = migrate_dialect_cls.visitor(name) + visitor = getattr(migrate_dialect_cls, name) # bind preparer visitor.preparer = sa_dialect.preparer(sa_dialect) return visitor + +def run_single_visitor(engine, visitorcallable, element, **kwargs): + """Runs only one method on the visitor""" + conn = engine.contextual_connect(close_with_result=False) + try: + visitor = visitorcallable(engine.dialect, conn) + getattr(visitor, 'visit_' + element.__visit_name__)(element, **kwargs) + finally: + conn.close() diff --git a/migrate/changeset/schema.py b/migrate/changeset/schema.py index dc75ac5..ea066aa 100644 --- a/migrate/changeset/schema.py +++ b/migrate/changeset/schema.py @@ -5,7 +5,9 @@ import re import sqlalchemy -from migrate.changeset.databases.visitor import get_engine_visitor +from migrate.changeset.databases.visitor import (get_engine_visitor, + run_single_visitor) +from migrate.changeset.exceptions import * __all__ = [ @@ -14,6 +16,9 @@ __all__ = [ 'alter_column', 'rename_table', 'rename_index', + 'ChangesetTable', + 'ChangesetColumn', + 'ChangesetIndex', ] @@ -97,7 +102,12 @@ def alter_column(*p, **k): engine = k['engine'] delta = _ColumnDelta(*p, **k) visitorcallable = get_engine_visitor(engine, 'schemachanger') - _engine_run_visitor(engine, visitorcallable, delta) + + column = sqlalchemy.Column(delta.current_name) + column.delta = delta + column.table = delta.table + engine._run_visitor(visitorcallable, column) + #_engine_run_visitor(engine, visitorcallable, delta) # Update column if col is not None: @@ -145,15 +155,6 @@ def _to_index(index, table=None, engine=None): return ret -def _engine_run_visitor(engine, visitorcallable, element, **kwargs): - conn = engine.connect() - try: - element.accept_schema_visitor(visitorcallable(engine.dialect, - connection=conn)) - finally: - conn.close() - - def _normalize_table(column, table): if table is not None: if table is not column.table: @@ -166,22 +167,6 @@ def _normalize_table(column, table): return column.table -class _WrapRename(object): - - def __init__(self, item, name): - self.item = item - self.name = name - - def accept_schema_visitor(self, visitor): - """Map Class (Table, Index, Column) to visitor function""" - suffix = self.item.__class__.__name__.lower() - funcname = 'visit_%s' % suffix - - func = getattr(visitor, funcname) - param = self.item, self.name - return func(param) - - class _ColumnDelta(dict): """Extracts the differences between two columns/column-parameters""" @@ -330,15 +315,14 @@ class ChangesetTable(object): Python object """ engine = self.bind + self.new_name = name visitorcallable = get_engine_visitor(engine, 'schemachanger') - param = _WrapRename(self, name) - _engine_run_visitor(engine, visitorcallable, param, *args, **kwargs) + run_single_visitor(engine, visitorcallable, self, *args, **kwargs) # Fix metadata registration - meta = self.metadata - self.deregister() self.name = name - self._set_parent(meta) + self.deregister() + self._set_parent(self.metadata) def _meta_key(self): return sqlalchemy.schema._get_table_key(self.name, self.schema) @@ -368,6 +352,9 @@ class ChangesetColumn(object): Column name, type, default, and nullable may be changed here. Note that for column defaults, only PassiveDefaults are managed by the database - changing others doesn't make sense. + + :param table: Table to be altered + :param engine: Engine to be used """ if 'table' not in k: k['table'] = self.table @@ -386,12 +373,6 @@ class ChangesetColumn(object): visitorcallable = get_engine_visitor(engine, 'columngenerator') engine._run_visitor(visitorcallable, self, *args, **kwargs) - # add in foreign keys - if self.foreign_keys: - for fk in self.foreign_keys: - visitorcallable = get_engine_visitor(engine, - 'columnfkgenerator') - engine._run_visitor(visitorcallable, self, fk=fk) return self def drop(self, table=None, *args, **kwargs): @@ -402,14 +383,15 @@ class ChangesetColumn(object): table = _normalize_table(self, table) engine = table.bind visitorcallable = get_engine_visitor(engine, 'columndropper') - engine._run_visitor(lambda dialect, conn: visitorcallable(conn), - self, *args, **kwargs) + engine._run_visitor(visitorcallable, self, *args, **kwargs) return self class ChangesetIndex(object): """Changeset extensions to SQLAlchemy Indexes.""" + __visit_name__ = 'index' + def rename(self, name, *args, **kwargs): """Change the name of an index. @@ -417,15 +399,7 @@ class ChangesetIndex(object): name. """ engine = self.table.bind + self.new_name = name visitorcallable = get_engine_visitor(engine, 'schemachanger') - param = _WrapRename(self, name) - _engine_run_visitor(engine, visitorcallable, param, *args, **kwargs) + engine._run_visitor(visitorcallable, self, *args, **kwargs) self.name = name - - -def _patch(): - """All the 'ugly' operations that patch SQLAlchemy's internals.""" - sqlalchemy.schema.Table.__bases__ += (ChangesetTable, ) - sqlalchemy.schema.Column.__bases__ += (ChangesetColumn, ) - sqlalchemy.schema.Index.__bases__ += (ChangesetIndex, ) -_patch() diff --git a/test/changeset/test_changeset.py b/test/changeset/test_changeset.py index 3c7eb9e..416a008 100644 --- a/test/changeset/test_changeset.py +++ b/test/changeset/test_changeset.py @@ -13,7 +13,7 @@ from migrate.changeset.schema import _ColumnDelta from test import fixture -# TODO: add sqlite unique constraints (indexes), test quoting +# TODO: test quoting class TestAddDropColumn(fixture.DB): level = fixture.DB.CONNECT @@ -25,8 +25,8 @@ class TestAddDropColumn(fixture.DB): def _setup(self, url): super(TestAddDropColumn, self)._setup(url) self.meta.clear() - self.table = Table(self.table_name,self.meta, - Column('id',Integer,primary_key=True), + self.table = Table(self.table_name, self.meta, + Column('id', Integer, primary_key=True), ) self.meta.bind = self.engine if self.engine.has_table(self.table.name): @@ -169,7 +169,7 @@ class TestAddDropColumn(fixture.DB): reftable.create() def add_func(col): self.table.append_column(col) - return create_column(col.name,self.table) + return create_column(col.name, self.table) def drop_func(col): ret = drop_column(col.name,self.table) if self.engine.has_table(reftable.name): @@ -180,12 +180,12 @@ class TestAddDropColumn(fixture.DB): self.run_, add_func, drop_func, Integer, ForeignKey(reftable.c.id)) else: - return self.run_(add_func,drop_func,Integer, + return self.run_(add_func, drop_func, Integer, ForeignKey(reftable.c.id)) class TestRename(fixture.DB): - level=fixture.DB.CONNECT + level = fixture.DB.CONNECT meta = MetaData() def _setup(self, url): @@ -195,25 +195,25 @@ class TestRename(fixture.DB): @fixture.usedb() def test_rename_table(self): """Tables can be renamed""" - #self.engine.echo=True + c_name = 'col_1' name1 = 'name_one' name2 = 'name_two' - xname1 = 'x'+name1 - xname2 = 'x'+name2 - self.column = Column(name1,Integer) + xname1 = 'x' + name1 + xname2 = 'x' + name2 + self.column = Column(c_name, Integer) self.meta.clear() - self.table = Table(name1,self.meta,self.column) - self.index = Index(xname1,self.column,unique=False) + self.table = Table(name1, self.meta, self.column) + self.index = Index(xname1, self.column, unique=False) if self.engine.has_table(self.table.name): self.table.drop() if self.engine.has_table(name2): - tmp = Table(name2,self.meta,autoload=True) + tmp = Table(name2, self.meta, autoload=True) tmp.drop() tmp.deregister() del tmp self.table.create() - def assert_table_name(expected,skip_object_check=False): + def assert_table_name(expected, skip_object_check=False): """Refresh a table via autoload SA has changed some since this test was written; we now need to do meta.clear() upon reloading a table - clear all rather than a @@ -245,18 +245,18 @@ class TestRename(fixture.DB): try: # Table renames assert_table_name(name1) - rename_table(self.table,name2) + rename_table(self.table, name2) assert_table_name(name2) self.table.rename(name1) assert_table_name(name1) # ..by just the string - rename_table(name1,name2,engine=self.engine) - assert_table_name(name2,True) # object not updated + rename_table(name1, name2, engine=self.engine) + assert_table_name(name2, True) # object not updated # Index renames if self.url.startswith('sqlite') or self.url.startswith('mysql'): self.assertRaises(changeset.exceptions.NotSupportedError, - self.index.rename,xname2) + self.index.rename, xname2) else: assert_index_name(xname1) rename_index(self.index,xname2,engine=self.engine) diff --git a/test/changeset/test_constraint.py b/test/changeset/test_constraint.py index da7fbe0..7e0d04d 100644 --- a/test/changeset/test_constraint.py +++ b/test/changeset/test_constraint.py @@ -3,38 +3,50 @@ from sqlalchemy import * from sqlalchemy.util import * +from sqlalchemy.exc import * from migrate.changeset import * from test import fixture -class TestConstraint(fixture.DB): - level = fixture.DB.CONNECT +class CommonTestConstraint(fixture.DB): + """helper functions to test constraints. + + we just create a fresh new table and make sure everything is + as required. + """ def _setup(self, url): - super(TestConstraint, self)._setup(url) + super(CommonTestConstraint, self)._setup(url) self._create_table() def _teardown(self): if hasattr(self, 'table') and self.engine.has_table(self.table.name): self.table.drop() - super(TestConstraint, self)._teardown() + super(CommonTestConstraint, self)._teardown() def _create_table(self): self._connect(self.url) self.meta = MetaData(self.engine) - self.table = Table('mytable', self.meta, - Column('id', Integer), + self.tablename = 'mytable' + self.table = Table(self.tablename, self.meta, + Column('id', Integer, unique=True), Column('fkey', Integer), mysql_engine='InnoDB') if self.engine.has_table(self.table.name): self.table.drop() self.table.create() + + # make sure we start at zero self.assertEquals(len(self.table.primary_key), 0) self.assert_(isinstance(self.table.primary_key, schema.PrimaryKeyConstraint), self.table.primary_key.__class__) + +class TestConstraint(CommonTestConstraint): + level = fixture.DB.CONNECT + def _define_pk(self, *cols): # Add a pk by creating a PK constraint pk = PrimaryKeyConstraint(table=self.table, *cols) @@ -46,7 +58,6 @@ class TestConstraint(fixture.DB): self.refresh_table() if not self.url.startswith('sqlite'): self.assertEquals(list(self.table.primary_key), list(cols)) - #self.assert_(self.table.primary_key.name is not None) # Drop the PK constraint if not self.url.startswith('oracle'): @@ -54,46 +65,34 @@ class TestConstraint(fixture.DB): pk.name = self.table.primary_key.name pk.drop() self.refresh_table() - #self.assertEquals(list(self.table.primary_key),list()) self.assertEquals(len(self.table.primary_key), 0) - self.assert_(isinstance(self.table.primary_key, - schema.PrimaryKeyConstraint),self.table.primary_key.__class__) + self.assert_(isinstance(self.table.primary_key, schema.PrimaryKeyConstraint)) return pk @fixture.usedb(not_supported='sqlite') def test_define_fk(self): """FK constraints can be defined, created, and dropped""" # FK target must be unique - pk = PrimaryKeyConstraint(self.table.c.id, table=self.table) + pk = PrimaryKeyConstraint(self.table.c.id, table=self.table, name="pkid") pk.create() + # Add a FK by creating a FK constraint self.assertEquals(self.table.c.fkey.foreign_keys._list, []) - fk = ForeignKeyConstraint([self.table.c.fkey],[self.table.c.id], table=self.table) + fk = ForeignKeyConstraint([self.table.c.fkey], [self.table.c.id], name="fk_id_fkey") self.assert_(self.table.c.fkey.foreign_keys._list is not []) self.assertEquals(list(fk.columns), [self.table.c.fkey]) - self.assertEquals([e.column for e in fk.elements],[self.table.c.id]) - self.assertEquals(list(fk.referenced),[self.table.c.id]) + self.assertEquals([e.column for e in fk.elements], [self.table.c.id]) + self.assertEquals(list(fk.referenced), [self.table.c.id]) if self.url.startswith('mysql'): # MySQL FKs need an index index = Index('index_name', self.table.c.fkey) index.create() - if self.url.startswith('oracle'): - # Oracle constraints need a name - fk.name = 'fgsfds' - print 'drop...' - #self.engine.echo=True fk.create() - #self.engine.echo=False - print 'dropped' self.refresh_table() self.assert_(self.table.c.fkey.foreign_keys._list is not []) - print 'drop...' - #self.engine.echo=True fk.drop() - #self.engine.echo=False - print 'dropped' self.refresh_table() self.assertEquals(self.table.c.fkey.foreign_keys._list, []) @@ -108,40 +107,123 @@ class TestConstraint(fixture.DB): #self.engine.echo=True self._define_pk(self.table.c.id, self.table.c.fkey) + @fixture.usedb() + def test_drop_cascade(self): + pk = PrimaryKeyConstraint('id', table=self.table, name="id_pkey") + pk.create() + self.refresh_table() -class TestAutoname(fixture.DB): + # Drop the PK constraint forcing cascade + pk.drop(cascade=True) + + +class TestAutoname(CommonTestConstraint): + """Every method tests for a type of constraint wether it can autoname + itself and if you can pass object instance and names to classes. + """ level = fixture.DB.CONNECT - def _setup(self, url): - super(TestAutoname, self)._setup(url) - self._connect(self.url) - self.meta = MetaData(self.engine) - self.table = Table('mytable',self.meta, - Column('id', Integer), - Column('fkey', String(40)), - ) - if self.engine.has_table(self.table.name): - self.table.drop() - self.table.create() - - def _teardown(self): - if hasattr(self,'table') and self.engine.has_table(self.table.name): - self.table.drop() - super(TestAutoname, self)._teardown() - @fixture.usedb(not_supported='oracle') - def test_autoname(self): - """Constraints can guess their name if none is given""" + def test_autoname_pk(self): + """PrimaryKeyConstraints can guess their name if None is given""" # Don't supply a name; it should create one cons = PrimaryKeyConstraint(self.table.c.id) cons.create() self.refresh_table() - # TODO: test for index for sqlite if not self.url.startswith('sqlite'): - self.assertEquals(list(cons.columns),list(self.table.primary_key)) + # TODO: test for index for sqlite + self.assertEquals(list(cons.columns), list(self.table.primary_key)) # Remove the name, drop the constraint; it should succeed cons.name = None cons.drop() self.refresh_table() self.assertEquals(list(), list(self.table.primary_key)) + + # test string names + cons = PrimaryKeyConstraint('id', table=self.table) + cons.create() + self.refresh_table() + if not self.url.startswith('sqlite'): + # TODO: test for index for sqlite + self.assertEquals(list(cons.columns), list(self.table.primary_key)) + cons.name = None + cons.drop() + + @fixture.usedb(not_supported=['oracle', 'sqlite']) + def test_autoname_fk(self): + """ForeignKeyConstraints can guess their name if None is given""" + cons = ForeignKeyConstraint([self.table.c.fkey], [self.table.c.id]) + if self.url.startswith('mysql'): + # MySQL FKs need an index + index = Index('index_name', self.table.c.fkey) + index.create() + cons.create() + self.refresh_table() + self.table.c.fkey.foreign_keys[0].column is self.table.c.id + + # Remove the name, drop the constraint; it should succeed + cons.name = None + cons.drop() + self.refresh_table() + self.assertEquals(self.table.c.fkey.foreign_keys._list, list()) + + # test string names + cons = ForeignKeyConstraint(['fkey'], ['%s.id' % self.tablename], table=self.table) + if self.url.startswith('mysql'): + # MySQL FKs need an index + index = Index('index_name', self.table.c.fkey) + index.create() + cons.create() + self.refresh_table() + self.table.c.fkey.foreign_keys[0].column is self.table.c.id + + # Remove the name, drop the constraint; it should succeed + cons.name = None + cons.drop() + + @fixture.usedb(not_supported=['oracle', 'sqlite']) + def test_autoname_check(self): + """CheckConstraints can guess their name if None is given""" + cons = CheckConstraint('id > 3', columns=[self.table.c.id]) + cons.create() + self.refresh_table() + + + self.table.insert(values={'id': 4}).execute() + try: + self.table.insert(values={'id': 1}).execute() + except IntegrityError: + pass + else: + self.fail() + + # Remove the name, drop the constraint; it should succeed + cons.name = None + cons.drop() + self.refresh_table() + self.table.insert(values={'id': 2}).execute() + self.table.insert(values={'id': 5}).execute() + + @fixture.usedb(not_supported=['oracle', 'sqlite']) + def test_autoname_unique(self): + """UniqueConstraints can guess their name if None is given""" + cons = UniqueConstraint(self.table.c.fkey) + cons.create() + self.refresh_table() + + + self.table.insert(values={'fkey': 4}).execute() + try: + self.table.insert(values={'fkey': 4}).execute() + except IntegrityError: + pass + else: + self.fail() + + # Remove the name, drop the constraint; it should succeed + cons.name = None + cons.drop() + self.refresh_table() + self.table.insert(values={'fkey': 4}).execute() + self.table.insert(values={'fkey': 4}).execute()