]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Recognize brackets, quoted_name in SQL Server schema
authorMike Bayer <mike_mp@zzzcomputing.com>
Mon, 3 Apr 2017 19:05:27 +0000 (15:05 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 4 Apr 2017 17:45:06 +0000 (13:45 -0400)
The SQL Server dialect now allows for a database and/or owner name
with a dot inside of it, using brackets explicitly in the string around
the owner and optionally the database name as well.  In addition,
sending the :class:`.quoted_name` construct for the schema name will
not split on the dot and will deliver the full string as the "owner".
:class:`.quoted_name` is also now available from the ``sqlalchemy.sql``
import space.

Change-Id: I77491d63ce47638bd23787d903ccde2f35a9d43d
Fixes: #2626
doc/build/changelog/changelog_12.rst
doc/build/changelog/migration_12.rst
lib/sqlalchemy/dialects/mssql/base.py
lib/sqlalchemy/sql/__init__.py
lib/sqlalchemy/sql/elements.py
lib/sqlalchemy/sql/expression.py
test/dialect/mssql/test_compiler.py

index ad9e73160f283c2be760c8af7ca0aad900c35ab2..31c866ee1e00f136d691323bd80f476dcac8c63b 100644 (file)
         check constraints in 0.9 as part of :ticket:`2742`, which more commonly
         feature Core SQL expressions as opposed to plain string expressions.
 
+    .. change:: 2626
+        :tags: bug, mssql
+        :tickets: 2626
+
+        The SQL Server dialect now allows for a database and/or owner name
+        with a dot inside of it, using brackets explicitly in the string around
+        the owner and optionally the database name as well.  In addition,
+        sending the :class:`.quoted_name` construct for the schema name will
+        not split on the dot and will deliver the full string as the "owner".
+        :class:`.quoted_name` is also now available from the ``sqlalchemy.sql``
+        import space.
+
+        .. seealso::
+
+            :ref:`change_2626`
+
     .. change:: 3923
         :tags: bug, sql
         :tickets: 3923
index 4c2090e50ee6c42208a1361ef723d4a79d8b0f7e..c5fe49dca721fcc5ff642afad786ddf0bf97bd09 100644 (file)
@@ -652,3 +652,47 @@ Where the above could create problems particularly with Alembic autogenerate.
 
 :ticket:`3276`
 
+
+Dialect Improvements and Changes - SQL Server
+=============================================
+
+.. _change_2626:
+
+SQL Server schema names with embedded dots supported
+-----------------------------------------------------
+
+The SQL Server dialect has a behavior such that a schema name with a dot inside
+of it is assumed to be a "database"."owner" identifier pair, which is
+necessarily split up into these separate components during table and component
+reflection operations, as well as when rendering quoting for the schema name so
+that the two symbols are quoted separately.  The schema argument can
+now be passed using brackets to manually specify where this split
+occurs, allowing database and/or owner names that themselves contain one
+or more dots::
+
+    Table(
+        "some_table", metadata,
+        Column("q", String(50)),
+        schema="[MyDataBase.dbo]"
+    )
+
+The above table will consider the "owner" to be ``MyDataBase.dbo``, which
+will also be quoted upon render, and the "database" as None.  To individually
+refer to database name and owner, use two pairs of brackets::
+
+    Table(
+        "some_table", metadata,
+        Column("q", String(50)),
+        schema="[MyDataBase.SomeDB].[MyDB.owner]"
+    )
+
+Additionally, the :class:`.quoted_name` construct is now honored when
+passed to "schema" by the SQL Server dialect; the given symbol will
+not be split on the dot if the quote flag is True and will be interpreted
+as the "owner".
+
+.. seealso::
+
+    :ref:`multipart_schema_names`
+
+:ticket:`2626`
index 6975754c6379a1315d3963ee49f50dbf7b3d38d8..814fc779931b7e03fc9728215ee8511e5a3be641 100644 (file)
@@ -333,6 +333,65 @@ behavior of this flag is as follows:
 
 .. versionadded:: 1.0.0
 
+.. _multipart_schema_names:
+
+Multipart Schema Names
+----------------------
+
+SQL Server schemas sometimes require multiple parts to their "schema"
+qualifier, that is, including the database name and owner name as separate
+tokens, such as ``mydatabase.dbo.some_table``. These multipart names can be set
+at once using the :paramref:`.Table.schema` argument of :class:`.Table`::
+
+    Table(
+        "some_table", metadata,
+        Column("q", String(50)),
+        schema="mydatabase.dbo"
+    )
+
+When performing operations such as table or component reflection, a schema
+argument that contains a dot will be split into separate
+"database" and "owner"  components in order to correctly query the SQL
+Server information schema tables, as these two values are stored separately.
+Additionally, when rendering the schema name for DDL or SQL, the two
+components will be quoted separately for case sensitive names and other
+special characters.   Given an argument as below::
+
+    Table(
+        "some_table", metadata,
+        Column("q", String(50)),
+        schema="MyDataBase.dbo"
+    )
+
+The above schema would be rendered as ``[MyDataBase].dbo``, and also in
+reflection, would be reflected using "dbo" as the owner and "MyDataBase"
+as the database name.
+
+To control how the schema name is broken into database / owner,
+specify brackets (which in SQL Server are quoting characters) in the name.
+Below, the "owner" will be considered as ``MyDataBase.dbo`` and the
+"database" will be None::
+
+    Table(
+        "some_table", metadata,
+        Column("q", String(50)),
+        schema="[MyDataBase.dbo]"
+    )
+
+To individually specify both database and owner name with special characters
+or embedded dots, use two sets of brackets::
+
+    Table(
+        "some_table", metadata,
+        Column("q", String(50)),
+        schema="[MyDataBase.Period].[MyOwner.Dot]"
+    )
+
+
+.. versionchanged:: 1.2 the SQL Server dialect now treats brackets as
+   identifier delimeters splitting the schema into separate database
+   and owner tokens, to allow dots within either name itself.
+
 .. _legacy_schema_rendering:
 
 Legacy Schema Mode
@@ -558,7 +617,7 @@ import operator
 import re
 
 from ... import sql, schema as sa_schema, exc, util
-from ...sql import compiler, expression, util as sql_util
+from ...sql import compiler, expression, util as sql_util, quoted_name
 from ... import engine
 from ...engine import reflection, default
 from ... import types as sqltypes
@@ -1550,9 +1609,18 @@ class MSIdentifierPreparer(compiler.IdentifierPreparer):
     def _escape_identifier(self, value):
         return value
 
+
     def quote_schema(self, schema, force=None):
         """Prepare a quoted table and schema name."""
-        result = '.'.join([self.quote(x, force) for x in schema.split('.')])
+
+        dbname, owner = _schema_elements(schema)
+        if dbname:
+            result = "%s.%s" % (
+                self.quote(dbname, force), self.quote(owner, force))
+        elif owner:
+            result = self.quote(owner, force)
+        else:
+            result = ""
         return result
 
 
@@ -1587,11 +1655,40 @@ def _owner_plus_db(dialect, schema):
     if not schema:
         return None, dialect.default_schema_name
     elif "." in schema:
-        return schema.split(".", 1)
+        return _schema_elements(schema)
     else:
         return None, schema
 
 
+def _schema_elements(schema):
+    if isinstance(schema, quoted_name) and schema.quote:
+        return None, schema
+
+    push = []
+    symbol = ""
+    bracket = False
+    for token in re.split(r"(\[|\]|\.)", schema):
+        if not token:
+            continue
+        if token == '[':
+            bracket = True
+        elif token == ']':
+            bracket = False
+        elif not bracket and token == ".":
+            push.append(symbol)
+            symbol = ""
+        else:
+            symbol += token
+    if symbol:
+        push.append(symbol)
+    if len(push) > 1:
+        return push[0], "".join(push[1:])
+    elif len(push):
+        return None, push[0]
+    else:
+        return None, None
+
+
 class MSDialect(default.DefaultDialect):
     name = 'mssql'
     supports_default_values = True
index 5eebd7d1c0bc96f4b719041cbde5d4b89595fc16..13042ed7a5e173a0ff2b35bba6ffba31b5c4735a 100644 (file)
@@ -57,6 +57,7 @@ from .expression import (
     outerjoin,
     outparam,
     over,
+    quoted_name,
     select,
     subquery,
     table,
index 1f812938297396f8b5c0baa1e7a0eb4ee7b0b5c2..001c3d042dd080a7be33379265b6babb6aaaa3d2 100644 (file)
@@ -3905,8 +3905,8 @@ class quoted_name(util.MemoizedSlots, util.text_type):
     can be quoted.  Such as to use the :meth:`.Engine.has_table` method with
     an unconditionally quoted name::
 
-        from sqlaclchemy import create_engine
-        from sqlalchemy.sql.elements import quoted_name
+        from sqlalchemy import create_engine
+        from sqlalchemy.sql import quoted_name
 
         engine = create_engine("oracle+cx_oracle://some_dsn")
         engine.has_table(quoted_name("some_table", True))
@@ -3917,6 +3917,10 @@ class quoted_name(util.MemoizedSlots, util.text_type):
 
     .. versionadded:: 0.9.0
 
+    .. versionchanged:: 1.2 The :class:`.quoted_name` construct is now
+       importable from ``sqlalchemy.sql``, in addition to the previous
+       location of ``sqlalchemy.sql.elements``.
+
     """
 
     __slots__ = 'quote', 'lower', 'upper'
index 172bf4b059b221a595a8d8b1f815d57286fc10b7..193dbaa96441a7ec9967868c74099736a027d178 100644 (file)
@@ -26,7 +26,8 @@ __all__ = [
     'nullslast',
     'or_', 'outparam', 'outerjoin', 'over', 'select', 'subquery',
     'table', 'text',
-    'tuple_', 'type_coerce', 'union', 'union_all', 'update', 'within_group',
+    'tuple_', 'type_coerce', 'quoted_name', 'union', 'union_all', 'update',
+    'within_group',
     'TableSample', 'tablesample']
 
 
@@ -37,7 +38,7 @@ from .elements import ClauseElement, ColumnElement,\
     BindParameter, CollectionAggregate, UnaryExpression, BooleanClauseList, \
     Label, Cast, Case, ColumnClause, TextClause, Over, Null, \
     True_, False_, BinaryExpression, Tuple, TypeClause, Extract, \
-    Grouping, WithinGroup, not_, \
+    Grouping, WithinGroup, not_, quoted_name, \
     collate, literal_column, between,\
     literal, outparam, TypeCoerce, ClauseList, FunctionFilter
 
index 10a7d09ab974e6c02dafb3ca5f16936ad4190543..1f4a4da4ba71dcd25aa441d616e310e1d2de57b1 100644 (file)
@@ -1,8 +1,8 @@
 # -*- encoding: utf-8
 from sqlalchemy.testing import eq_, is_
 from sqlalchemy import schema
-from sqlalchemy.sql import table, column
-from sqlalchemy.databases import mssql
+from sqlalchemy.sql import table, column, quoted_name
+from sqlalchemy.dialects import mssql
 from sqlalchemy.dialects.mssql import mxodbc
 from sqlalchemy.testing import fixtures, AssertsCompiledSQL
 from sqlalchemy import sql
@@ -10,6 +10,7 @@ from sqlalchemy import Integer, String, Table, Column, select, MetaData,\
     update, delete, insert, extract, union, func, PrimaryKeyConstraint, \
     UniqueConstraint, Index, Sequence, literal
 from sqlalchemy import testing
+from sqlalchemy.dialects.mssql import base
 
 
 class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
@@ -270,6 +271,93 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
                             "myid FROM mytable) AS foo, mytable WHERE "
                             "foo.myid = mytable.myid")
 
+    def test_force_schema_quoted_name_w_dot_case_insensitive(self):
+        metadata = MetaData()
+        tbl = Table(
+            'test', metadata,
+            Column('id', Integer, primary_key=True),
+            schema=quoted_name("foo.dbo", True)
+        )
+        self.assert_compile(
+            select([tbl]),
+            "SELECT [foo.dbo].test.id FROM [foo.dbo].test"
+        )
+
+    def test_force_schema_quoted_w_dot_case_insensitive(self):
+        metadata = MetaData()
+        tbl = Table(
+            'test', metadata,
+            Column('id', Integer, primary_key=True),
+            schema=quoted_name("foo.dbo", True)
+        )
+        self.assert_compile(
+            select([tbl]),
+            "SELECT [foo.dbo].test.id FROM [foo.dbo].test"
+        )
+
+    def test_force_schema_quoted_name_w_dot_case_sensitive(self):
+        metadata = MetaData()
+        tbl = Table(
+            'test', metadata,
+            Column('id', Integer, primary_key=True),
+            schema=quoted_name("Foo.dbo", True)
+        )
+        self.assert_compile(
+            select([tbl]),
+            "SELECT [Foo.dbo].test.id FROM [Foo.dbo].test"
+        )
+
+    def test_force_schema_quoted_w_dot_case_sensitive(self):
+        metadata = MetaData()
+        tbl = Table(
+            'test', metadata,
+            Column('id', Integer, primary_key=True),
+            schema="[Foo.dbo]"
+        )
+        self.assert_compile(
+            select([tbl]),
+            "SELECT [Foo.dbo].test.id FROM [Foo.dbo].test"
+        )
+
+    def test_schema_autosplit_w_dot_case_insensitive(self):
+        metadata = MetaData()
+        tbl = Table(
+            'test', metadata,
+            Column('id', Integer, primary_key=True),
+            schema="foo.dbo"
+        )
+        self.assert_compile(
+            select([tbl]),
+            "SELECT foo.dbo.test.id FROM foo.dbo.test"
+        )
+
+    def test_schema_autosplit_w_dot_case_sensitive(self):
+        metadata = MetaData()
+        tbl = Table(
+            'test', metadata,
+            Column('id', Integer, primary_key=True),
+            schema="Foo.dbo"
+        )
+        self.assert_compile(
+            select([tbl]),
+            "SELECT [Foo].dbo.test.id FROM [Foo].dbo.test"
+        )
+
+    def test_owner_database_pairs(self):
+        dialect = mssql.dialect()
+
+        for identifier, expected_schema, expected_owner in [
+            ("foo", None, "foo"),
+            ("foo.bar", "foo", "bar"),
+            ("Foo.Bar", "Foo", "Bar"),
+            ("[Foo.Bar]", None, "Foo.Bar"),
+            ("[Foo.Bar].[bat]", "Foo.Bar", "bat"),
+        ]:
+            schema, owner = base._owner_plus_db(dialect, identifier)
+
+            eq_(owner, expected_owner)
+            eq_(schema, expected_schema)
+
     def test_delete_schema(self):
         metadata = MetaData()
         tbl = Table('test', metadata, Column('id', Integer,
@@ -478,7 +566,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "SELECT TOP 0 t.x, t.y FROM t WHERE t.x = :x_1 ORDER BY t.y",
             checkparams={'x_1': 5}
         )
-        c = s.compile(dialect=mssql.MSDialect())
+        c = s.compile(dialect=mssql.dialect())
         eq_(len(c._result_columns), 2)
         assert t.c.x in set(c._create_result_map()['x'][1])
 
@@ -499,7 +587,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
                 checkparams={'param_1': 20, 'x_1': 5}
             )
 
-            c = s.compile(dialect=mssql.MSDialect())
+            c = s.compile(dialect=mssql.dialect())
             eq_(len(c._result_columns), 2)
             assert t.c.x in set(c._create_result_map()['x'][1])
 
@@ -518,7 +606,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "WHERE mssql_rn > :param_1 AND mssql_rn <= :param_2 + :param_1",
             checkparams={'param_1': 20, 'param_2': 10, 'x_1': 5}
         )
-        c = s.compile(dialect=mssql.MSDialect())
+        c = s.compile(dialect=mssql.dialect())
         eq_(len(c._result_columns), 2)
         assert t.c.x in set(c._create_result_map()['x'][1])
         assert t.c.y in set(c._create_result_map()['y'][1])
@@ -539,7 +627,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             "WHERE mssql_rn > :param_1 AND mssql_rn <= :param_2 + :param_1",
             checkparams={'param_1': 20, 'param_2': 10, 'x_1': 5}
         )
-        c = s.compile(dialect=mssql.MSDialect())
+        c = s.compile(dialect=mssql.dialect())
         eq_(len(c._result_columns), 4)
 
         result_map = c._create_result_map()
@@ -568,7 +656,7 @@ class CompileTest(fixtures.TestBase, AssertsCompiledSQL):
             checkparams={'param_1': 20, 'param_2': 10, 'x_1': 5}
         )
 
-        c = s.compile(dialect=mssql.MSDialect())
+        c = s.compile(dialect=mssql.dialect())
         eq_(len(c._result_columns), 2)
         assert t1.c.x in set(c._create_result_map()['x'][1])
         assert t1.c.y in set(c._create_result_map()['y'][1])