comparison rhodecode/lib/dbmigrate/migrate/changeset/schema.py @ 833:9753e0907827 beta

added dbmigrate package, added model changes moved out upgrade db command to that package
author Marcin Kuzminski <marcin@python-works.com>
date Sat, 11 Dec 2010 01:54:12 +0100
parents
children 08d2dcd71666
comparison
equal deleted inserted replaced
832:634596f81cfd 833:9753e0907827
1 """
2 Schema module providing common schema operations.
3 """
4 import warnings
5
6 from UserDict import DictMixin
7
8 import sqlalchemy
9
10 from sqlalchemy.schema import ForeignKeyConstraint
11 from sqlalchemy.schema import UniqueConstraint
12
13 from migrate.exceptions import *
14 from migrate.changeset import SQLA_06
15 from migrate.changeset.databases.visitor import (get_engine_visitor,
16 run_single_visitor)
17
18
19 __all__ = [
20 'create_column',
21 'drop_column',
22 'alter_column',
23 'rename_table',
24 'rename_index',
25 'ChangesetTable',
26 'ChangesetColumn',
27 'ChangesetIndex',
28 'ChangesetDefaultClause',
29 'ColumnDelta',
30 ]
31
32 DEFAULT_ALTER_METADATA = True
33
34
35 def create_column(column, table=None, *p, **kw):
36 """Create a column, given the table.
37
38 API to :meth:`ChangesetColumn.create`.
39 """
40 if table is not None:
41 return table.create_column(column, *p, **kw)
42 return column.create(*p, **kw)
43
44
45 def drop_column(column, table=None, *p, **kw):
46 """Drop a column, given the table.
47
48 API to :meth:`ChangesetColumn.drop`.
49 """
50 if table is not None:
51 return table.drop_column(column, *p, **kw)
52 return column.drop(*p, **kw)
53
54
55 def rename_table(table, name, engine=None, **kw):
56 """Rename a table.
57
58 If Table instance is given, engine is not used.
59
60 API to :meth:`ChangesetTable.rename`.
61
62 :param table: Table to be renamed.
63 :param name: New name for Table.
64 :param engine: Engine instance.
65 :type table: string or Table instance
66 :type name: string
67 :type engine: obj
68 """
69 table = _to_table(table, engine)
70 table.rename(name, **kw)
71
72
73 def rename_index(index, name, table=None, engine=None, **kw):
74 """Rename an index.
75
76 If Index instance is given,
77 table and engine are not used.
78
79 API to :meth:`ChangesetIndex.rename`.
80
81 :param index: Index to be renamed.
82 :param name: New name for index.
83 :param table: Table to which Index is reffered.
84 :param engine: Engine instance.
85 :type index: string or Index instance
86 :type name: string
87 :type table: string or Table instance
88 :type engine: obj
89 """
90 index = _to_index(index, table, engine)
91 index.rename(name, **kw)
92
93
94 def alter_column(*p, **k):
95 """Alter a column.
96
97 This is a helper function that creates a :class:`ColumnDelta` and
98 runs it.
99
100 :argument column:
101 The name of the column to be altered or a
102 :class:`ChangesetColumn` column representing it.
103
104 :param table:
105 A :class:`~sqlalchemy.schema.Table` or table name to
106 for the table where the column will be changed.
107
108 :param engine:
109 The :class:`~sqlalchemy.engine.base.Engine` to use for table
110 reflection and schema alterations.
111
112 :param alter_metadata:
113 If `True`, which is the default, the
114 :class:`~sqlalchemy.schema.Column` will also modified.
115 If `False`, the :class:`~sqlalchemy.schema.Column` will be left
116 as it was.
117
118 :returns: A :class:`ColumnDelta` instance representing the change.
119
120
121 """
122
123 k.setdefault('alter_metadata', DEFAULT_ALTER_METADATA)
124
125 if 'table' not in k and isinstance(p[0], sqlalchemy.Column):
126 k['table'] = p[0].table
127 if 'engine' not in k:
128 k['engine'] = k['table'].bind
129
130 # deprecation
131 if len(p) >= 2 and isinstance(p[1], sqlalchemy.Column):
132 warnings.warn(
133 "Passing a Column object to alter_column is deprecated."
134 " Just pass in keyword parameters instead.",
135 MigrateDeprecationWarning
136 )
137 engine = k['engine']
138 delta = ColumnDelta(*p, **k)
139
140 visitorcallable = get_engine_visitor(engine, 'schemachanger')
141 engine._run_visitor(visitorcallable, delta)
142
143 return delta
144
145
146 def _to_table(table, engine=None):
147 """Return if instance of Table, else construct new with metadata"""
148 if isinstance(table, sqlalchemy.Table):
149 return table
150
151 # Given: table name, maybe an engine
152 meta = sqlalchemy.MetaData()
153 if engine is not None:
154 meta.bind = engine
155 return sqlalchemy.Table(table, meta)
156
157
158 def _to_index(index, table=None, engine=None):
159 """Return if instance of Index, else construct new with metadata"""
160 if isinstance(index, sqlalchemy.Index):
161 return index
162
163 # Given: index name; table name required
164 table = _to_table(table, engine)
165 ret = sqlalchemy.Index(index)
166 ret.table = table
167 return ret
168
169
170 class ColumnDelta(DictMixin, sqlalchemy.schema.SchemaItem):
171 """Extracts the differences between two columns/column-parameters
172
173 May receive parameters arranged in several different ways:
174
175 * **current_column, new_column, \*p, \*\*kw**
176 Additional parameters can be specified to override column
177 differences.
178
179 * **current_column, \*p, \*\*kw**
180 Additional parameters alter current_column. Table name is extracted
181 from current_column object.
182 Name is changed to current_column.name from current_name,
183 if current_name is specified.
184
185 * **current_col_name, \*p, \*\*kw**
186 Table kw must specified.
187
188 :param table: Table at which current Column should be bound to.\
189 If table name is given, reflection will be used.
190 :type table: string or Table instance
191 :param alter_metadata: If True, it will apply changes to metadata.
192 :type alter_metadata: bool
193 :param metadata: If `alter_metadata` is true, \
194 metadata is used to reflect table names into
195 :type metadata: :class:`MetaData` instance
196 :param engine: When reflecting tables, either engine or metadata must \
197 be specified to acquire engine object.
198 :type engine: :class:`Engine` instance
199 :returns: :class:`ColumnDelta` instance provides interface for altered attributes to \
200 `result_column` through :func:`dict` alike object.
201
202 * :class:`ColumnDelta`.result_column is altered column with new attributes
203
204 * :class:`ColumnDelta`.current_name is current name of column in db
205
206
207 """
208
209 # Column attributes that can be altered
210 diff_keys = ('name', 'type', 'primary_key', 'nullable',
211 'server_onupdate', 'server_default', 'autoincrement')
212 diffs = dict()
213 __visit_name__ = 'column'
214
215 def __init__(self, *p, **kw):
216 self.alter_metadata = kw.pop("alter_metadata", False)
217 self.meta = kw.pop("metadata", None)
218 self.engine = kw.pop("engine", None)
219
220 # Things are initialized differently depending on how many column
221 # parameters are given. Figure out how many and call the appropriate
222 # method.
223 if len(p) >= 1 and isinstance(p[0], sqlalchemy.Column):
224 # At least one column specified
225 if len(p) >= 2 and isinstance(p[1], sqlalchemy.Column):
226 # Two columns specified
227 diffs = self.compare_2_columns(*p, **kw)
228 else:
229 # Exactly one column specified
230 diffs = self.compare_1_column(*p, **kw)
231 else:
232 # Zero columns specified
233 if not len(p) or not isinstance(p[0], basestring):
234 raise ValueError("First argument must be column name")
235 diffs = self.compare_parameters(*p, **kw)
236
237 self.apply_diffs(diffs)
238
239 def __repr__(self):
240 return '<ColumnDelta altermetadata=%r, %s>' % (self.alter_metadata,
241 super(ColumnDelta, self).__repr__())
242
243 def __getitem__(self, key):
244 if key not in self.keys():
245 raise KeyError("No such diff key, available: %s" % self.diffs)
246 return getattr(self.result_column, key)
247
248 def __setitem__(self, key, value):
249 if key not in self.keys():
250 raise KeyError("No such diff key, available: %s" % self.diffs)
251 setattr(self.result_column, key, value)
252
253 def __delitem__(self, key):
254 raise NotImplementedError
255
256 def keys(self):
257 return self.diffs.keys()
258
259 def compare_parameters(self, current_name, *p, **k):
260 """Compares Column objects with reflection"""
261 self.table = k.pop('table')
262 self.result_column = self._table.c.get(current_name)
263 if len(p):
264 k = self._extract_parameters(p, k, self.result_column)
265 return k
266
267 def compare_1_column(self, col, *p, **k):
268 """Compares one Column object"""
269 self.table = k.pop('table', None)
270 if self.table is None:
271 self.table = col.table
272 self.result_column = col
273 if len(p):
274 k = self._extract_parameters(p, k, self.result_column)
275 return k
276
277 def compare_2_columns(self, old_col, new_col, *p, **k):
278 """Compares two Column objects"""
279 self.process_column(new_col)
280 self.table = k.pop('table', None)
281 # we cannot use bool() on table in SA06
282 if self.table is None:
283 self.table = old_col.table
284 if self.table is None:
285 new_col.table
286 self.result_column = old_col
287
288 # set differences
289 # leave out some stuff for later comp
290 for key in (set(self.diff_keys) - set(('type',))):
291 val = getattr(new_col, key, None)
292 if getattr(self.result_column, key, None) != val:
293 k.setdefault(key, val)
294
295 # inspect types
296 if not self.are_column_types_eq(self.result_column.type, new_col.type):
297 k.setdefault('type', new_col.type)
298
299 if len(p):
300 k = self._extract_parameters(p, k, self.result_column)
301 return k
302
303 def apply_diffs(self, diffs):
304 """Populate dict and column object with new values"""
305 self.diffs = diffs
306 for key in self.diff_keys:
307 if key in diffs:
308 setattr(self.result_column, key, diffs[key])
309
310 self.process_column(self.result_column)
311
312 # create an instance of class type if not yet
313 if 'type' in diffs and callable(self.result_column.type):
314 self.result_column.type = self.result_column.type()
315
316 # add column to the table
317 if self.table is not None and self.alter_metadata:
318 self.result_column.add_to_table(self.table)
319
320 def are_column_types_eq(self, old_type, new_type):
321 """Compares two types to be equal"""
322 ret = old_type.__class__ == new_type.__class__
323
324 # String length is a special case
325 if ret and isinstance(new_type, sqlalchemy.types.String):
326 ret = (getattr(old_type, 'length', None) == \
327 getattr(new_type, 'length', None))
328 return ret
329
330 def _extract_parameters(self, p, k, column):
331 """Extracts data from p and modifies diffs"""
332 p = list(p)
333 while len(p):
334 if isinstance(p[0], basestring):
335 k.setdefault('name', p.pop(0))
336 elif isinstance(p[0], sqlalchemy.types.AbstractType):
337 k.setdefault('type', p.pop(0))
338 elif callable(p[0]):
339 p[0] = p[0]()
340 else:
341 break
342
343 if len(p):
344 new_col = column.copy_fixed()
345 new_col._init_items(*p)
346 k = self.compare_2_columns(column, new_col, **k)
347 return k
348
349 def process_column(self, column):
350 """Processes default values for column"""
351 # XXX: this is a snippet from SA processing of positional parameters
352 if not SQLA_06 and column.args:
353 toinit = list(column.args)
354 else:
355 toinit = list()
356
357 if column.server_default is not None:
358 if isinstance(column.server_default, sqlalchemy.FetchedValue):
359 toinit.append(column.server_default)
360 else:
361 toinit.append(sqlalchemy.DefaultClause(column.server_default))
362 if column.server_onupdate is not None:
363 if isinstance(column.server_onupdate, FetchedValue):
364 toinit.append(column.server_default)
365 else:
366 toinit.append(sqlalchemy.DefaultClause(column.server_onupdate,
367 for_update=True))
368 if toinit:
369 column._init_items(*toinit)
370
371 if not SQLA_06:
372 column.args = []
373
374 def _get_table(self):
375 return getattr(self, '_table', None)
376
377 def _set_table(self, table):
378 if isinstance(table, basestring):
379 if self.alter_metadata:
380 if not self.meta:
381 raise ValueError("metadata must be specified for table"
382 " reflection when using alter_metadata")
383 meta = self.meta
384 if self.engine:
385 meta.bind = self.engine
386 else:
387 if not self.engine and not self.meta:
388 raise ValueError("engine or metadata must be specified"
389 " to reflect tables")
390 if not self.engine:
391 self.engine = self.meta.bind
392 meta = sqlalchemy.MetaData(bind=self.engine)
393 self._table = sqlalchemy.Table(table, meta, autoload=True)
394 elif isinstance(table, sqlalchemy.Table):
395 self._table = table
396 if not self.alter_metadata:
397 self._table.meta = sqlalchemy.MetaData(bind=self._table.bind)
398
399 def _get_result_column(self):
400 return getattr(self, '_result_column', None)
401
402 def _set_result_column(self, column):
403 """Set Column to Table based on alter_metadata evaluation."""
404 self.process_column(column)
405 if not hasattr(self, 'current_name'):
406 self.current_name = column.name
407 if self.alter_metadata:
408 self._result_column = column
409 else:
410 self._result_column = column.copy_fixed()
411
412 table = property(_get_table, _set_table)
413 result_column = property(_get_result_column, _set_result_column)
414
415
416 class ChangesetTable(object):
417 """Changeset extensions to SQLAlchemy tables."""
418
419 def create_column(self, column, *p, **kw):
420 """Creates a column.
421
422 The column parameter may be a column definition or the name of
423 a column in this table.
424
425 API to :meth:`ChangesetColumn.create`
426
427 :param column: Column to be created
428 :type column: Column instance or string
429 """
430 if not isinstance(column, sqlalchemy.Column):
431 # It's a column name
432 column = getattr(self.c, str(column))
433 column.create(table=self, *p, **kw)
434
435 def drop_column(self, column, *p, **kw):
436 """Drop a column, given its name or definition.
437
438 API to :meth:`ChangesetColumn.drop`
439
440 :param column: Column to be droped
441 :type column: Column instance or string
442 """
443 if not isinstance(column, sqlalchemy.Column):
444 # It's a column name
445 try:
446 column = getattr(self.c, str(column))
447 except AttributeError:
448 # That column isn't part of the table. We don't need
449 # its entire definition to drop the column, just its
450 # name, so create a dummy column with the same name.
451 column = sqlalchemy.Column(str(column), sqlalchemy.Integer())
452 column.drop(table=self, *p, **kw)
453
454 def rename(self, name, connection=None, **kwargs):
455 """Rename this table.
456
457 :param name: New name of the table.
458 :type name: string
459 :param alter_metadata: If True, table will be removed from metadata
460 :type alter_metadata: bool
461 :param connection: reuse connection istead of creating new one.
462 :type connection: :class:`sqlalchemy.engine.base.Connection` instance
463 """
464 self.alter_metadata = kwargs.pop('alter_metadata', DEFAULT_ALTER_METADATA)
465 engine = self.bind
466 self.new_name = name
467 visitorcallable = get_engine_visitor(engine, 'schemachanger')
468 run_single_visitor(engine, visitorcallable, self, connection, **kwargs)
469
470 # Fix metadata registration
471 if self.alter_metadata:
472 self.name = name
473 self.deregister()
474 self._set_parent(self.metadata)
475
476 def _meta_key(self):
477 return sqlalchemy.schema._get_table_key(self.name, self.schema)
478
479 def deregister(self):
480 """Remove this table from its metadata"""
481 key = self._meta_key()
482 meta = self.metadata
483 if key in meta.tables:
484 del meta.tables[key]
485
486
487 class ChangesetColumn(object):
488 """Changeset extensions to SQLAlchemy columns."""
489
490 def alter(self, *p, **k):
491 """Makes a call to :func:`alter_column` for the column this
492 method is called on.
493 """
494 if 'table' not in k:
495 k['table'] = self.table
496 if 'engine' not in k:
497 k['engine'] = k['table'].bind
498 return alter_column(self, *p, **k)
499
500 def create(self, table=None, index_name=None, unique_name=None,
501 primary_key_name=None, populate_default=True, connection=None, **kwargs):
502 """Create this column in the database.
503
504 Assumes the given table exists. ``ALTER TABLE ADD COLUMN``,
505 for most databases.
506
507 :param table: Table instance to create on.
508 :param index_name: Creates :class:`ChangesetIndex` on this column.
509 :param unique_name: Creates :class:\
510 `~migrate.changeset.constraint.UniqueConstraint` on this column.
511 :param primary_key_name: Creates :class:\
512 `~migrate.changeset.constraint.PrimaryKeyConstraint` on this column.
513 :param alter_metadata: If True, column will be added to table object.
514 :param populate_default: If True, created column will be \
515 populated with defaults
516 :param connection: reuse connection istead of creating new one.
517 :type table: Table instance
518 :type index_name: string
519 :type unique_name: string
520 :type primary_key_name: string
521 :type alter_metadata: bool
522 :type populate_default: bool
523 :type connection: :class:`sqlalchemy.engine.base.Connection` instance
524
525 :returns: self
526 """
527 self.populate_default = populate_default
528 self.alter_metadata = kwargs.pop('alter_metadata', DEFAULT_ALTER_METADATA)
529 self.index_name = index_name
530 self.unique_name = unique_name
531 self.primary_key_name = primary_key_name
532 for cons in ('index_name', 'unique_name', 'primary_key_name'):
533 self._check_sanity_constraints(cons)
534
535 if self.alter_metadata:
536 self.add_to_table(table)
537 engine = self.table.bind
538 visitorcallable = get_engine_visitor(engine, 'columngenerator')
539 engine._run_visitor(visitorcallable, self, connection, **kwargs)
540
541 # TODO: reuse existing connection
542 if self.populate_default and self.default is not None:
543 stmt = table.update().values({self: engine._execute_default(self.default)})
544 engine.execute(stmt)
545
546 return self
547
548 def drop(self, table=None, connection=None, **kwargs):
549 """Drop this column from the database, leaving its table intact.
550
551 ``ALTER TABLE DROP COLUMN``, for most databases.
552
553 :param alter_metadata: If True, column will be removed from table object.
554 :type alter_metadata: bool
555 :param connection: reuse connection istead of creating new one.
556 :type connection: :class:`sqlalchemy.engine.base.Connection` instance
557 """
558 self.alter_metadata = kwargs.pop('alter_metadata', DEFAULT_ALTER_METADATA)
559 if table is not None:
560 self.table = table
561 engine = self.table.bind
562 if self.alter_metadata:
563 self.remove_from_table(self.table, unset_table=False)
564 visitorcallable = get_engine_visitor(engine, 'columndropper')
565 engine._run_visitor(visitorcallable, self, connection, **kwargs)
566 if self.alter_metadata:
567 self.table = None
568 return self
569
570 def add_to_table(self, table):
571 if table is not None and self.table is None:
572 self._set_parent(table)
573
574 def _col_name_in_constraint(self, cons, name):
575 return False
576
577 def remove_from_table(self, table, unset_table=True):
578 # TODO: remove primary keys, constraints, etc
579 if unset_table:
580 self.table = None
581
582 to_drop = set()
583 for index in table.indexes:
584 columns = []
585 for col in index.columns:
586 if col.name != self.name:
587 columns.append(col)
588 if columns:
589 index.columns = columns
590 else:
591 to_drop.add(index)
592 table.indexes = table.indexes - to_drop
593
594 to_drop = set()
595 for cons in table.constraints:
596 # TODO: deal with other types of constraint
597 if isinstance(cons, (ForeignKeyConstraint,
598 UniqueConstraint)):
599 for col_name in cons.columns:
600 if not isinstance(col_name, basestring):
601 col_name = col_name.name
602 if self.name == col_name:
603 to_drop.add(cons)
604 table.constraints = table.constraints - to_drop
605
606 if table.c.contains_column(self):
607 table.c.remove(self)
608
609 # TODO: this is fixed in 0.6
610 def copy_fixed(self, **kw):
611 """Create a copy of this ``Column``, with all attributes."""
612 return sqlalchemy.Column(self.name, self.type, self.default,
613 key=self.key,
614 primary_key=self.primary_key,
615 nullable=self.nullable,
616 quote=self.quote,
617 index=self.index,
618 unique=self.unique,
619 onupdate=self.onupdate,
620 autoincrement=self.autoincrement,
621 server_default=self.server_default,
622 server_onupdate=self.server_onupdate,
623 *[c.copy(**kw) for c in self.constraints])
624
625 def _check_sanity_constraints(self, name):
626 """Check if constraints names are correct"""
627 obj = getattr(self, name)
628 if (getattr(self, name[:-5]) and not obj):
629 raise InvalidConstraintError("Column.create() accepts index_name,"
630 " primary_key_name and unique_name to generate constraints")
631 if not isinstance(obj, basestring) and obj is not None:
632 raise InvalidConstraintError(
633 "%s argument for column must be constraint name" % name)
634
635
636 class ChangesetIndex(object):
637 """Changeset extensions to SQLAlchemy Indexes."""
638
639 __visit_name__ = 'index'
640
641 def rename(self, name, connection=None, **kwargs):
642 """Change the name of an index.
643
644 :param name: New name of the Index.
645 :type name: string
646 :param alter_metadata: If True, Index object will be altered.
647 :type alter_metadata: bool
648 :param connection: reuse connection istead of creating new one.
649 :type connection: :class:`sqlalchemy.engine.base.Connection` instance
650 """
651 self.alter_metadata = kwargs.pop('alter_metadata', DEFAULT_ALTER_METADATA)
652 engine = self.table.bind
653 self.new_name = name
654 visitorcallable = get_engine_visitor(engine, 'schemachanger')
655 engine._run_visitor(visitorcallable, self, connection, **kwargs)
656 if self.alter_metadata:
657 self.name = name
658
659
660 class ChangesetDefaultClause(object):
661 """Implements comparison between :class:`DefaultClause` instances"""
662
663 def __eq__(self, other):
664 if isinstance(other, self.__class__):
665 if self.arg == other.arg:
666 return True
667
668 def __ne__(self, other):
669 return not self.__eq__(other)