]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- Added a new feature which allows automated naming conventions to be
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 1 Feb 2014 23:21:04 +0000 (18:21 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 1 Feb 2014 23:21:04 +0000 (18:21 -0500)
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

doc/build/changelog/changelog_09.rst
doc/build/core/constraints.rst
lib/sqlalchemy/sql/__init__.py
lib/sqlalchemy/sql/naming.py [new file with mode: 0644]
lib/sqlalchemy/sql/schema.py
lib/sqlalchemy/testing/fixtures.py
test/engine/test_reflection.py
test/sql/test_metadata.py

index 8995f7d399e3a4ff2d41fe50373ea53e4354f471..a218e0f547076a733270de13cf2daf18ba008bdc 100644 (file)
 .. changelog::
     :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
index 13ead6fbfb2683e855669af3a98c3943433c1e0a..d9a8fa98abcb8fa8ac5106e8effb51c5e96e9cbc 100644 (file)
@@ -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
index 9ed6049af1e7c02332a7eac57c95b854ef557ed9..95dae5aa35aeacb5c0a690f23acad882baef60d0 100644 (file)
@@ -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 (file)
index 0000000..b2cf1e9
--- /dev/null
@@ -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
index ba38b5070a43ac1cf2b526d82c2b367febf6d169..621ac20e8f1b72883776c206658977c40e719c62 100644 (file)
@@ -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)
index 28541b14b50a3133eb8dfb3da131a3f0015093da..8717ce7649e0e6c41ab3686c00c7b93f924ed947 100644 (file)
@@ -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):
 
index 2f311f7e711d217a3d1fb900eea215cd5706b555..77e75447532c543cc7298cff2473d16ba10b8b80 100644 (file)
@@ -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)
 
index f933a2494f522fac6a1eb717ce891a366379d8d3..36c777c9a2aec49eee592069ac8d7da059bf8251 100644 (file)
@@ -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")