]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
parse for parenthesis in referenced tablename, columnname
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 12 Sep 2023 15:05:48 +0000 (11:05 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Mon, 18 Sep 2023 18:21:37 +0000 (14:21 -0400)
Fixed a series of reflection issues affecting the PostgreSQL,
MySQL/MariaDB, and SQLite dialects when reflecting foreign key constraints
where the target column contained parenthesis in one or both of the table
name or column name.

Fixes: #10275
Change-Id: Ia2393d45416af6b36e7cab4ee10c2ade7a7e49b3

doc/build/changelog/unreleased_20/10275.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/mysql/reflection.py
lib/sqlalchemy/dialects/postgresql/base.py
lib/sqlalchemy/dialects/sqlite/base.py
lib/sqlalchemy/testing/suite/test_reflection.py
test/dialect/postgresql/test_dialect.py

diff --git a/doc/build/changelog/unreleased_20/10275.rst b/doc/build/changelog/unreleased_20/10275.rst
new file mode 100644 (file)
index 0000000..a3eb576
--- /dev/null
@@ -0,0 +1,9 @@
+.. change::
+    :tags: bug, engine
+    :tickets: 10275
+
+    Fixed a series of reflection issues affecting the PostgreSQL,
+    MySQL/MariaDB, and SQLite dialects when reflecting foreign key constraints
+    where the target column contained parenthesis in one or both of the table
+    name or column name.
+
index ce1b9261de40a5378e8a8be6a284c6ae4301c834..c4909fe319e0dddf60340608fa789fec0283d02c 100644 (file)
@@ -509,7 +509,7 @@ class MySQLTableDefinitionParser:
             r"\((?P<local>[^\)]+?)\) REFERENCES +"
             r"(?P<table>%(iq)s[^%(fq)s]+%(fq)s"
             r"(?:\.%(iq)s[^%(fq)s]+%(fq)s)?) +"
-            r"\((?P<foreign>[^\)]+?)\)"
+            r"\((?P<foreign>(?:%(iq)s[^%(fq)s]+%(fq)s(?: *, *)?)+)\)"
             r"(?: +(?P<match>MATCH \w+))?"
             r"(?: +ON DELETE (?P<ondelete>%(on)s))?"
             r"(?: +ON UPDATE (?P<onupdate>%(on)s))?" % kw
index 08fd5d3a04d92913a264ac1a1d3565ef6921a3ce..9a7447661ded648a7747f48d193340d57119e7bc 100644 (file)
@@ -4114,9 +4114,13 @@ class PGDialect(default.DefaultDialect):
 
     @util.memoized_property
     def _fk_regex_pattern(self):
+        # optionally quoted token
+        qtoken = '(?:"[^"]+"|[A-Za-z0-9_]+?)'
+
         # https://www.postgresql.org/docs/current/static/sql-createtable.html
         return re.compile(
-            r"FOREIGN KEY \((.*?)\) REFERENCES (?:(.*?)\.)?(.*?)\((.*?)\)"
+            r"FOREIGN KEY \((.*?)\) "
+            rf"REFERENCES (?:({qtoken})\.)?({qtoken})\(((?:{qtoken}(?: *, *)?)+)\)"  # noqa: E501
             r"[\s]?(MATCH (FULL|PARTIAL|SIMPLE)+)?"
             r"[\s]?(ON UPDATE "
             r"(CASCADE|RESTRICT|NO ACTION|SET NULL|SET DEFAULT)+)?"
index bd6c1dff87b384b91192ac069fb9a3586c2dc962..5790c1fe443e7b095db1b6f34a2523f66ecf668f 100644 (file)
@@ -2444,10 +2444,16 @@ class SQLiteDialect(default.DefaultDialect):
             if table_data is None:
                 # system tables, etc.
                 return
+
+            # note that we already have the FKs from PRAGMA above.  This whole
+            # regexp thing is trying to locate additional detail about the
+            # FKs, namely the name of the constraint and other options.
+            # so parsing the columns is really about matching it up to what
+            # we already have.
             FK_PATTERN = (
                 r"(?:CONSTRAINT (\w+) +)?"
                 r"FOREIGN KEY *\( *(.+?) *\) +"
-                r'REFERENCES +(?:(?:"(.+?)")|([a-z0-9_]+)) *\((.+?)\) *'
+                r'REFERENCES +(?:(?:"(.+?)")|([a-z0-9_]+)) *\( *((?:(?:"[^"]+"|[a-z0-9_]+) *(?:, *)?)+)\) *'  # noqa: E501
                 r"((?:ON (?:DELETE|UPDATE) "
                 r"(?:SET NULL|SET DEFAULT|CASCADE|RESTRICT|NO ACTION) *)*)"
                 r"((?:NOT +)?DEFERRABLE)?"
index 05b68e7aef23878d3498a27233a12435ffb3d59e..f2ecf1cae95dc42861b5d62873165347273171e0 100644 (file)
@@ -287,6 +287,65 @@ class HasIndexTest(fixtures.TablesTest):
         )
 
 
+class BizarroCharacterFKResolutionTest(fixtures.TestBase):
+    """tests for #10275"""
+
+    __backend__ = True
+
+    @testing.combinations(
+        ("id",), ("(3)",), ("col%p",), ("[brack]",), argnames="columnname"
+    )
+    @testing.variation("use_composite", [True, False])
+    @testing.combinations(
+        ("plain",),
+        ("(2)",),
+        ("per % cent",),
+        ("[brackets]",),
+        argnames="tablename",
+    )
+    def test_fk_ref(
+        self, connection, metadata, use_composite, tablename, columnname
+    ):
+        tt = Table(
+            tablename,
+            metadata,
+            Column(columnname, Integer, key="id", primary_key=True),
+            test_needs_fk=True,
+        )
+        if use_composite:
+            tt.append_column(Column("id2", Integer, primary_key=True))
+
+        if use_composite:
+            Table(
+                "other",
+                metadata,
+                Column("id", Integer, primary_key=True),
+                Column("ref", Integer),
+                Column("ref2", Integer),
+                sa.ForeignKeyConstraint(["ref", "ref2"], [tt.c.id, tt.c.id2]),
+                test_needs_fk=True,
+            )
+        else:
+            Table(
+                "other",
+                metadata,
+                Column("id", Integer, primary_key=True),
+                Column("ref", ForeignKey(tt.c.id)),
+                test_needs_fk=True,
+            )
+
+        metadata.create_all(connection)
+
+        m2 = MetaData()
+
+        o2 = Table("other", m2, autoload_with=connection)
+        t1 = m2.tables[tablename]
+
+        assert o2.c.ref.references(t1.c[0])
+        if use_composite:
+            assert o2.c.ref2.references(t1.c[1])
+
+
 class QuotedNameArgumentTest(fixtures.TablesTest):
     run_create_tables = "once"
     __backend__ = True
@@ -3053,6 +3112,7 @@ __all__ = (
     "ComponentReflectionTestExtra",
     "TableNoColumnsTest",
     "QuotedNameArgumentTest",
+    "BizarroCharacterFKResolutionTest",
     "HasTableTest",
     "HasIndexTest",
     "NormalizedNameTest",
index b86ac55fdbb9834df11acef85f4aa7f97c059bb4..db2d5e73dc6cbb45e39d61e3395909c0ff5cef65 100644 (file)
@@ -3,6 +3,7 @@ import dataclasses
 import datetime
 import logging
 import logging.handlers
+import re
 
 from sqlalchemy import BigInteger
 from sqlalchemy import bindparam
@@ -59,9 +60,101 @@ from sqlalchemy.testing.assertions import expect_raises
 from sqlalchemy.testing.assertions import ne_
 
 
+def _fk_expected(
+    constrained_columns,
+    referred_table,
+    referred_columns,
+    referred_schema=None,
+    match=None,
+    onupdate=None,
+    ondelete=None,
+    deferrable=None,
+    initially=None,
+):
+    return (
+        constrained_columns,
+        referred_schema,
+        referred_table,
+        referred_columns,
+        f"MATCH {match}" if match else None,
+        match,
+        f"ON UPDATE {onupdate}" if onupdate else None,
+        onupdate,
+        f"ON DELETE {ondelete}" if ondelete else None,
+        ondelete,
+        deferrable,
+        f"INITIALLY {initially}" if initially else None,
+        initially,
+    )
+
+
 class DialectTest(fixtures.TestBase):
     """python-side dialect tests."""
 
+    @testing.combinations(
+        (
+            "FOREIGN KEY (tid) REFERENCES some_table(id)",
+            _fk_expected("tid", "some_table", "id"),
+        ),
+        (
+            'FOREIGN KEY (tid) REFERENCES "(2)"(id)',
+            _fk_expected("tid", '"(2)"', "id"),
+        ),
+        (
+            'FOREIGN KEY (tid) REFERENCES some_table("(2)")',
+            _fk_expected("tid", "some_table", '"(2)"'),
+        ),
+        (
+            'FOREIGN KEY (tid1, tid2) REFERENCES some_table("(2)", "(3)")',
+            _fk_expected("tid1, tid2", "some_table", '"(2)", "(3)"'),
+        ),
+        (
+            "FOREIGN KEY (tid) REFERENCES some_table(id) "
+            "DEFERRABLE INITIALLY DEFERRED",
+            _fk_expected(
+                "tid",
+                "some_table",
+                "id",
+                deferrable="DEFERRABLE",
+                initially="DEFERRED",
+            ),
+        ),
+        (
+            "FOREIGN KEY (tid1, tid2) "
+            "REFERENCES some_schema.some_table(id1, id2)",
+            _fk_expected(
+                "tid1, tid2",
+                "some_table",
+                "id1, id2",
+                referred_schema="some_schema",
+            ),
+        ),
+        (
+            "FOREIGN KEY (tid1, tid2) "
+            "REFERENCES some_schema.some_table(id1, id2) "
+            "MATCH FULL "
+            "ON UPDATE CASCADE "
+            "ON DELETE CASCADE "
+            "DEFERRABLE INITIALLY DEFERRED",
+            _fk_expected(
+                "tid1, tid2",
+                "some_table",
+                "id1, id2",
+                referred_schema="some_schema",
+                onupdate="CASCADE",
+                ondelete="CASCADE",
+                match="FULL",
+                deferrable="DEFERRABLE",
+                initially="DEFERRED",
+            ),
+        ),
+    )
+    def test_fk_parsing(self, condef, expected):
+        FK_REGEX = postgresql.dialect()._fk_regex_pattern
+        groups = re.search(FK_REGEX, condef).groups()
+
+        eq_(groups, expected)
+
     def test_range_constructor(self):
         """test kwonly argments in the range constructor, as we had
         to do dataclasses backwards compat operations"""