diff options
author | Mike Bayer <mike_mp@zzzcomputing.com> | 2014-02-01 18:21:04 -0500 |
---|---|---|
committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2014-02-01 18:21:04 -0500 |
commit | 5b0919f3f5c7678c587858a47e38acd4a5b82f25 (patch) | |
tree | 236ff9c4c4e8688aa9b1b5b5a9ea7f20ea3807fd | |
parent | 32a1db368599f6f3dbb3765ef5f11640d1725672 (diff) | |
download | sqlalchemy-5b0919f3f5c7678c587858a47e38acd4a5b82f25.tar.gz |
- Added a new feature which allows automated naming conventions to be
applied to :class:`.Constraint` and :class:`.Index` objects. Based
on a recipe in the wiki, the new feature uses schema-events to set up
names as various schema objects are associated with each other. The
events then expose a configuration system through a new argument
:paramref:`.MetaData.naming_convention`. This system allows production
of both simple and custom naming schemes for constraints and indexes
on a per-:class:`.MetaData` basis. [ticket:2923]
commit 7e65e52c086652de3dd3303c723f98f09af54db8
Author: Mike Bayer <mike_mp@zzzcomputing.com>
Date: Sat Feb 1 15:09:04 2014 -0500
- first pass at new naming approach
-rw-r--r-- | doc/build/changelog/changelog_09.rst | 17 | ||||
-rw-r--r-- | doc/build/core/constraints.rst | 160 | ||||
-rw-r--r-- | lib/sqlalchemy/sql/__init__.py | 3 | ||||
-rw-r--r-- | lib/sqlalchemy/sql/naming.py | 110 | ||||
-rw-r--r-- | lib/sqlalchemy/sql/schema.py | 84 | ||||
-rw-r--r-- | lib/sqlalchemy/testing/fixtures.py | 18 | ||||
-rw-r--r-- | test/engine/test_reflection.py | 7 | ||||
-rw-r--r-- | test/sql/test_metadata.py | 82 |
8 files changed, 460 insertions, 21 deletions
diff --git a/doc/build/changelog/changelog_09.rst b/doc/build/changelog/changelog_09.rst index 8995f7d39..a218e0f54 100644 --- a/doc/build/changelog/changelog_09.rst +++ b/doc/build/changelog/changelog_09.rst @@ -15,6 +15,23 @@ :version: 0.9.2 .. change:: + :tags: feature, sql + :tickets: 2923 + + Added a new feature which allows automated naming conventions to be + applied to :class:`.Constraint` and :class:`.Index` objects. Based + on a recipe in the wiki, the new feature uses schema-events to set up + names as various schema objects are associated with each other. The + events then expose a configuration system through a new argument + :paramref:`.MetaData.naming_convention`. This system allows production + of both simple and custom naming schemes for constraints and indexes + on a per-:class:`.MetaData` basis. + + .. seealso:: + + :ref:`constraint_naming_conventions` + + .. change:: :tags: bug, orm :tickets: 2921 diff --git a/doc/build/core/constraints.rst b/doc/build/core/constraints.rst index 13ead6fbf..d9a8fa98a 100644 --- a/doc/build/core/constraints.rst +++ b/doc/build/core/constraints.rst @@ -266,6 +266,166 @@ To apply table-level constraint objects such as :class:`.ForeignKeyConstraint` to a table defined using Declarative, use the ``__table_args__`` attribute, described at :ref:`declarative_table_args`. +.. _constraint_naming_conventions: + +Configuring Constraint Naming Conventions +----------------------------------------- + +Relational databases typically assign explicit names to all constraints and +indexes. In the common case that a table is created using ``CREATE TABLE`` +where constraints such as CHECK, UNIQUE, and PRIMARY KEY constraints are +produced inline with the table definition, the database usually has a system +in place in which names are automatically assigned to these constraints, if +a name is not otherwise specified. When an existing database table is altered +in a database using a command such as ``ALTER TABLE``, this command typically +needs to specify expicit names for new constraints as well as be able to +specify the name of an existing constraint that is to be dropped or modified. + +Constraints can be named explicitly using the :paramref:`.Constraint.name` parameter, +and for indexes the :paramref:`.Index.name` parameter. However, in the +case of constraints this parameter is optional. There are also the use +cases of using the :paramref:`.Column.unique` and :paramref:`.Column.index` +parameters which create :class:`.UniqueConstraint` and :class:`.Index` objects +without an explicit name being specified. + +The use case of alteration of existing tables and constraints can be handled +by schema migration tools such as `Alembic <http://http://alembic.readthedocs.org/>`_. +However, neither Alembic nor SQLAlchemy currently create names for constraint +objects where the name is otherwise unspecified, leading to the case where +being able to alter existing constraints means that one must reverse-engineer +the naming system used by the relational database to auto-assign names, +or that care must be taken to ensure that all constraints are named. + +In contrast to having to assign explicit names to all :class:`.Constraint` +and :class:`.Index` objects, automated naming schemes can be constructed +using events. This approach has the advantage that constraints will get +a consistent naming scheme without the need for explicit name parameters +throughout the code, and also that the convention takes place just as well +for those constraints and indexes produced by the :paramref:`.Column.unique` +and :paramref:`.Column.index` parameters. As of SQLAlchemy 0.9.2 this +event-based approach is included, and can be configured using the argument +:paramref:`.MetaData.naming_convention`. + +:paramref:`.MetaData.naming_convention` refers to a dictionary which accepts +the :class:`.Index` class or individual :class:`.Constraint` classes as keys, +and Python string templates as values. It also accepts a series of +string-codes as alternative keys, ``"fk"``, ``"pk"``, +``"ix"``, ``"ck"``, ``"uq"`` for foreign key, primary key, index, +check, and unique constraint, respectively. The string templates in this +dictionary are used whenever a constraint or index is associated with this +:class:`.MetaData` object that does not have an existing name given (including +one exception case where an existing name can be further embellished). + +An example naming convention that suits basic cases is as follows:: + + convention = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" + } + + metadata = MetaData(naming_convention=convention) + +The above convention will establish names for all constraints within +the target :class:`.MetaData` collection. +For example, we can observe the name produced when we create an unnamed +:class:`.UniqueConstraint`:: + + >>> user_table = Table('user', metadata, + ... Column('id', Integer, primary_key=True), + ... Column('name', String(30), nullable=False), + ... UniqueConstraint('name') + ... ) + >>> list(user_table.constraints)[1].name + 'uq_user_name' + +This same feature takes effect even if we just use the :paramref:`.Column.unique` +flag:: + + >>> user_table = Table('user', metadata, + ... Column('id', Integer, primary_key=True), + ... Column('name', String(30), nullable=False, unique=True) + ... ) + >>> list(user_table.constraints)[1].name + 'uq_user_name' + +A key advantage to the naming convention approach is that the names are established +at Python construction time, rather than at DDL emit time. The effect this has +when using Alembic's ``--autogenerate`` feature is that the naming convention +will be explicit when a new migration script is generated:: + + def upgrade(): + op.create_unique_constraint("uq_user_name", "user", ["name"]) + +The above ``"uq_user_name"`` string was copied from the :class:`.UniqueConstraint` +object that ``--autogenerate`` located in our metadata. + +The default value for :paramref:`.MetaData.naming_convention` handles +the long-standing SQLAlchemy behavior of assigning a name to a :class:`.Index` +object that is created using the :paramref:`.Column.index` parameter:: + + >>> from sqlalchemy.sql.schema import DEFAULT_NAMING_CONVENTION + >>> DEFAULT_NAMING_CONVENTION + immutabledict({'ix': 'ix_%(column_0_label)s'}) + +The tokens available include ``%(table_name)s``, +``%(referred_table_name)s``, ``%(column_0_name)s``, ``%(column_0_label)s``, +``%(column_0_key)s``, ``%(referred_column_0_name)s``, and ``%(constraint_name)s``; +the documentation for :paramref:`.MetaData.naming_convention` describes each +individually. New tokens can also be added, by specifying an additional +token and a callable within the naming_convention dictionary. For example, +if we wanted to name our foreign key constraints using a GUID scheme, +we could do that as follows:: + + import uuid + + def fk_guid(constraint, table): + str_tokens = [ + table.name, + ] + [ + element.parent.name for element in constraint.elements + ] + [ + element.target_fullname for element in constraint.elements + ] + guid = uuid.uuid5(uuid.NAMESPACE_OID, "_".join(str_tokens).encode('ascii')) + return str(guid) + + convention = { + "fk_guid": fk_guid, + "ix": 'ix_%(column_0_label)s', + "fk": "fk_%(fk_guid)s", + } + +Above, when we create a new :class:`.ForeignKeyConstraint`, we will get a +name as follows:: + + >>> metadata = MetaData(naming_convention=convention) + + >>> user_table = Table('user', metadata, + ... Column('id', Integer, primary_key=True), + ... Column('version', Integer, primary_key=True), + ... Column('data', String(30)) + ... ) + >>> address_table = Table('address', metadata, + ... Column('id', Integer, primary_key=True), + ... Column('user_id', Integer), + ... Column('user_version_id', Integer) + ... ) + >>> fk = ForeignKeyConstraint(['user_id', 'user_version_id'], + ... ['user.id', 'user.version']) + >>> address_table.append_constraint(fk) + >>> fk.name + fk_0cd51ab5-8d70-56e8-a83c-86661737766d + +.. seealso:: + + :paramref:`.MetaData.naming_convention` - for additional usage details + as well as a listing of all avaiable naming components. + +.. versionadded:: 0.9.2 Added the :paramref:`.MetaData.naming_convention` argument. + Constraints API --------------- .. autoclass:: Constraint diff --git a/lib/sqlalchemy/sql/__init__.py b/lib/sqlalchemy/sql/__init__.py index 9ed6049af..95dae5aa3 100644 --- a/lib/sqlalchemy/sql/__init__.py +++ b/lib/sqlalchemy/sql/__init__.py @@ -66,7 +66,6 @@ from .expression import ( from .visitors import ClauseVisitor - def __go(lcls): global __all__ from .. import util as _sa_util @@ -85,5 +84,7 @@ def __go(lcls): _sa_util.dependencies.resolve_all("sqlalchemy.sql") + from . import naming + __go(locals()) diff --git a/lib/sqlalchemy/sql/naming.py b/lib/sqlalchemy/sql/naming.py new file mode 100644 index 000000000..b2cf1e9a5 --- /dev/null +++ b/lib/sqlalchemy/sql/naming.py @@ -0,0 +1,110 @@ +# sqlalchemy/naming.py +# Copyright (C) 2005-2014 the SQLAlchemy authors and contributors <see AUTHORS file> +# +# This module is part of SQLAlchemy and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php + +"""Establish constraint and index naming conventions. + + +""" + +from .schema import Constraint, ForeignKeyConstraint, PrimaryKeyConstraint, \ + UniqueConstraint, CheckConstraint, Index, Table +from .. import event, events +from .. import exc +from .elements import _truncated_label +import re + +class ConventionDict(object): + def __init__(self, const, table, convention): + self.const = const + self._is_fk = isinstance(const, ForeignKeyConstraint) + self.table = table + self.convention = convention + self._const_name = const.name + + def _key_table_name(self): + return self.table.name + + def _column_X(self, idx): + if self._is_fk: + fk = self.const.elements[idx] + return fk.parent + else: + return list(self.const.columns)[idx] + + def _key_constraint_name(self): + if not self._const_name: + raise exc.InvalidRequestError( + "Naming convention including " + "%(constraint_name)s token requires that " + "constraint is explicitly named." + ) + # they asked for a name that's derived from the existing + # name, so set the existing name to None + self.const.name = None + return self._const_name + + def _key_column_X_name(self, idx): + return self._column_X(idx).name + + def _key_column_X_label(self, idx): + return self._column_X(idx)._label + + def _key_referred_table_name(self): + fk = self.const.elements[0] + reftable, refcol = fk.target_fullname.split(".") + return reftable + + def _key_referred_column_X_name(self, idx): + fk = self.const.elements[idx] + reftable, refcol = fk.target_fullname.split(".") + return refcol + + def __getitem__(self, key): + if key in self.convention: + return self.convention[key](self.const, self.table) + elif hasattr(self, '_key_%s' % key): + return getattr(self, '_key_%s' % key)() + else: + col_template = re.match(r".*_?column_(\d+)_.+", key) + if col_template: + idx = col_template.group(1) + attr = "_key_" + key.replace(idx, "X") + idx = int(idx) + if hasattr(self, attr): + return getattr(self, attr)(idx) + raise KeyError(key) + +_prefix_dict = { + Index: "ix", + PrimaryKeyConstraint: "pk", + CheckConstraint: "ck", + UniqueConstraint: "uq", + ForeignKeyConstraint: "fk" +} + +def _get_convention(dict_, key): + + for super_ in key.__mro__: + if super_ in _prefix_dict and _prefix_dict[super_] in dict_: + return dict_[_prefix_dict[super_]] + elif super_ in dict_: + return dict_[super_] + else: + return None + + +@event.listens_for(Constraint, "after_parent_attach") +@event.listens_for(Index, "after_parent_attach") +def _constraint_name(const, table): + if isinstance(table, Table): + metadata = table.metadata + convention = _get_convention(metadata.naming_convention, type(const)) + if convention is not None: + newname = _truncated_label( + convention % ConventionDict(const, table, metadata.naming_convention) + ) + if const.name is None: + const.name = newname diff --git a/lib/sqlalchemy/sql/schema.py b/lib/sqlalchemy/sql/schema.py index ba38b5070..621ac20e8 100644 --- a/lib/sqlalchemy/sql/schema.py +++ b/lib/sqlalchemy/sql/schema.py @@ -1130,8 +1130,7 @@ class Column(SchemaItem, ColumnClause): "The 'index' keyword argument on Column is boolean only. " "To create indexes with a specific name, create an " "explicit Index object external to the Table.") - Index(_truncated_label('ix_%s' % self._label), - self, unique=bool(self.unique)) + Index(None, self, unique=bool(self.unique)) elif self.unique: if isinstance(self.unique, util.string_types): raise exc.ArgumentError( @@ -2240,12 +2239,12 @@ class ColumnCollectionConstraint(ColumnCollectionMixin, Constraint): arguments are propagated to the :class:`.Constraint` superclass. """ - ColumnCollectionMixin.__init__(self, *columns) Constraint.__init__(self, **kw) + ColumnCollectionMixin.__init__(self, *columns) def _set_parent(self, table): - ColumnCollectionMixin._set_parent(self, table) Constraint._set_parent(self, table) + ColumnCollectionMixin._set_parent(self, table) def __contains__(self, x): return x in self.columns @@ -2839,6 +2838,11 @@ class Index(DialectKWArgs, ColumnCollectionMixin, SchemaItem): )) +DEFAULT_NAMING_CONVENTION = util.immutabledict({ + "ix": 'ix_%(column_0_label)s' +}) + + class MetaData(SchemaItem): """A collection of :class:`.Table` objects and their associated schema constructs. @@ -2865,7 +2869,9 @@ class MetaData(SchemaItem): __visit_name__ = 'metadata' def __init__(self, bind=None, reflect=False, schema=None, - quote_schema=None): + quote_schema=None, + naming_convention=DEFAULT_NAMING_CONVENTION + ): """Create a new MetaData object. :param bind: @@ -2890,12 +2896,76 @@ class MetaData(SchemaItem): :class:`.Sequence`, and other objects which make usage of the local ``schema`` name. - .. versionadded:: 0.7.4 - ``schema`` and ``quote_schema`` parameters. + :param naming_convention: a dictionary referring to values which + will establish default naming conventions for :class:`.Constraint` + and :class:`.Index` objects, for those objects which are not given + a name explicitly. + + The keys of this dictionary may be: + + * a constraint or Index class, e.g. the :class:`.UniqueConstraint`, + :class:`.ForeignKeyConstraint` class, the :class:`.Index` class + + * a string mnemonic for one of the known constraint classes; + ``"fk"``, ``"pk"``, ``"ix"``, ``"ck"``, ``"uq"`` for foreign key, + primary key, index, check, and unique constraint, respectively. + + * the string name of a user-defined "token" that can be used + to define new naming tokens. + + The values associated with each "constraint class" or "constraint + mnemonic" key are string naming templates, such as + ``"uq_%(table_name)s_%(column_0_name)s"``, + which decribe how the name should be composed. The values associated + with user-defined "token" keys should be callables of the form + ``fn(constraint, table)``, which accepts the constraint/index + object and :class:`.Table` as arguments, returning a string + result. + + The built-in names are as follows, some of which may only be + available for certain types of constraint: + + * ``%(table_name)s`` - the name of the :class:`.Table` object + associated with the constraint. + + * ``%(referred_table_name)s`` - the name of the :class:`.Table` + object associated with the referencing target of a + :class:`.ForeignKeyConstraint`. + + * ``%(column_0_name)s`` - the name of the :class:`.Column` at + index position "0" within the constraint. + + * ``%(column_0_label)s`` - the label of the :class:`.Column` at + index position "0", e.g. :attr:`.Column.label` + + * ``%(column_0_key)s`` - the key of the :class:`.Column` at + index position "0", e.g. :attr:`.Column.key` + + * ``%(referred_column_0_name)s`` - the name of a :class:`.Column` + at index position "0" referenced by a :class:`.ForeignKeyConstraint`. + + * ``%(constraint_name)s`` - a special key that refers to the existing + name given to the constraint. When this key is present, the + :class:`.Constraint` object's existing name will be replaced with + one that is composed from template string that uses this token. + When this token is present, it is required that the :class:`.Constraint` + is given an expicit name ahead of time. + + * user-defined: any additional token may be implemented by passing + it along with a ``fn(constraint, table)`` callable to the + naming_convention dictionary. + + .. versionadded:: 0.9.2 + + .. seealso:: + + :ref:`constraint_naming_conventions` - for detailed usage + examples. """ self.tables = util.immutabledict() self.schema = quoted_name(schema, quote_schema) + self.naming_convention = naming_convention self._schemas = set() self._sequences = {} self._fk_memos = collections.defaultdict(list) diff --git a/lib/sqlalchemy/testing/fixtures.py b/lib/sqlalchemy/testing/fixtures.py index 28541b14b..8717ce764 100644 --- a/lib/sqlalchemy/testing/fixtures.py +++ b/lib/sqlalchemy/testing/fixtures.py @@ -210,6 +210,24 @@ class TablesTest(TestBase): [dict(zip(headers[table], column_values)) for column_values in rows[table]]) +from sqlalchemy import event +class RemovesEvents(object): + @util.memoized_property + def _event_fns(self): + return set() + + def event_listen(self, target, name, fn): + self._event_fns.add((target, name, fn)) + event.listen(target, name, fn) + + def teardown(self): + for key in self._event_fns: + event.remove(*key) + super_ = super(RemovesEvents, self) + if hasattr(super_, "teardown"): + super_.teardown() + + class _ORMTest(object): diff --git a/test/engine/test_reflection.py b/test/engine/test_reflection.py index 2f311f7e7..77e754475 100644 --- a/test/engine/test_reflection.py +++ b/test/engine/test_reflection.py @@ -1501,7 +1501,7 @@ class CaseSensitiveTest(fixtures.TablesTest): -class ColumnEventsTest(fixtures.TestBase): +class ColumnEventsTest(fixtures.RemovesEvents, fixtures.TestBase): @classmethod def setup_class(cls): @@ -1526,9 +1526,6 @@ class ColumnEventsTest(fixtures.TestBase): def teardown_class(cls): cls.metadata.drop_all(testing.db) - def teardown(self): - events.SchemaEventTarget.dispatch._clear() - def _do_test(self, col, update, assert_, tablename="to_reflect"): # load the actual Table class, not the test # wrapper @@ -1545,7 +1542,7 @@ class ColumnEventsTest(fixtures.TestBase): assert_(t) m = MetaData(testing.db) - event.listen(Table, 'column_reflect', column_reflect) + self.event_listen(Table, 'column_reflect', column_reflect) t2 = Table(tablename, m, autoload=True) assert_(t2) diff --git a/test/sql/test_metadata.py b/test/sql/test_metadata.py index f933a2494..36c777c9a 100644 --- a/test/sql/test_metadata.py +++ b/test/sql/test_metadata.py @@ -2024,10 +2024,7 @@ class ColumnOptionsTest(fixtures.TestBase): c.info['bar'] = 'zip' assert c.info['bar'] == 'zip' -class CatchAllEventsTest(fixtures.TestBase): - - def teardown(self): - events.SchemaEventTarget.dispatch._clear() +class CatchAllEventsTest(fixtures.RemovesEvents, fixtures.TestBase): def test_all_events(self): canary = [] @@ -2038,8 +2035,8 @@ class CatchAllEventsTest(fixtures.TestBase): def after_attach(obj, parent): canary.append("%s->%s" % (obj.__class__.__name__, parent)) - event.listen(schema.SchemaItem, "before_parent_attach", before_attach) - event.listen(schema.SchemaItem, "after_parent_attach", after_attach) + self.event_listen(schema.SchemaItem, "before_parent_attach", before_attach) + self.event_listen(schema.SchemaItem, "after_parent_attach", after_attach) m = MetaData() Table('t1', m, @@ -2074,8 +2071,8 @@ class CatchAllEventsTest(fixtures.TestBase): def after_attach(obj, parent): assert hasattr(obj, 'name') # so we can change it canary.append("%s->%s" % (target.__name__, parent)) - event.listen(target, "before_parent_attach", before_attach) - event.listen(target, "after_parent_attach", after_attach) + self.event_listen(target, "before_parent_attach", before_attach) + self.event_listen(target, "after_parent_attach", after_attach) for target in [ schema.ForeignKeyConstraint, schema.PrimaryKeyConstraint, @@ -2384,3 +2381,72 @@ class DialectKWArgTest(fixtures.TestBase): "participating_y": True, 'participating2_y': "p2y", "participating_z_one": "default"}) + +class NamingConventionTest(fixtures.TestBase): + def _fixture(self, naming_convention): + m1 = MetaData(naming_convention=naming_convention) + + u1 = Table('user', m1, + Column('id', Integer, primary_key=True), + Column('version', Integer, primary_key=True), + Column('data', String(30)) + ) + + return u1 + + def test_uq_name(self): + u1 = self._fixture(naming_convention={ + "uq": "uq_%(table_name)s_%(column_0_name)s" + }) + uq = UniqueConstraint(u1.c.data) + eq_(uq.name, "uq_user_data") + + def test_ck_name(self): + u1 = self._fixture(naming_convention={ + "ck": "ck_%(table_name)s_%(constraint_name)s" + }) + ck = CheckConstraint(u1.c.data == 'x', name='mycheck') + eq_(ck.name, "ck_user_mycheck") + + assert_raises_message( + exc.InvalidRequestError, + r"Naming convention including %\(constraint_name\)s token " + "requires that constraint is explicitly named.", + CheckConstraint, u1.c.data == 'x' + ) + + def test_fk_attrs(self): + u1 = self._fixture(naming_convention={ + "fk": "fk_%(table_name)s_%(column_0_name)s_" + "%(referred_table_name)s_%(referred_column_0_name)s" + }) + m1 = u1.metadata + a1 = Table('address', m1, + Column('id', Integer, primary_key=True), + Column('user_id', Integer), + Column('user_version_id', Integer) + ) + fk = ForeignKeyConstraint(['user_id', 'user_version_id'], + ['user.id', 'user.version']) + a1.append_constraint(fk) + eq_(fk.name, "fk_address_user_id_user_id") + + + def test_custom(self): + def key_hash(const, table): + return "HASH_%s" % table.name + + u1 = self._fixture(naming_convention={ + "fk": "fk_%(table_name)s_%(key_hash)s", + "key_hash": key_hash + }) + m1 = u1.metadata + a1 = Table('address', m1, + Column('id', Integer, primary_key=True), + Column('user_id', Integer), + Column('user_version_id', Integer) + ) + fk = ForeignKeyConstraint(['user_id', 'user_version_id'], + ['user.id', 'user.version']) + a1.append_constraint(fk) + eq_(fk.name, "fk_address_HASH_address") |