]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
fix catastrophic backtracking in sqlite inline unique reflection regex
authorJavid Khan <dxbjavid@gmail.com>
Wed, 8 Jul 2026 14:20:43 +0000 (10:20 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 11 Jul 2026 19:16:40 +0000 (15:16 -0400)
Fixes: #13419
### Description

While reflecting a SQLite table, `get_unique_constraints` scans the stored
`CREATE TABLE` text (taken verbatim from `sqlite_master.sql`) with an
`INLINE_UNIQUE_PATTERN` to spot inline `UNIQUE` columns. The tail of that
pattern is `[\t ]+[a-z0-9_ ]+?[\t ]+UNIQUE`, where the lazy middle class
itself contains a space, so all three quantifiers can lay claim to the same
space character. When a column definition contains a run of whitespace that
isn't followed by `UNIQUE`, the engine tries every way of splitting that run
across the three quantifiers, which is cubic in the length of the run.

SQLite keeps the original whitespace of a `CREATE TABLE` statement in
`sqlite_master`, so any account that can create a table can leave a long gap
in a column definition and make later reflection of that schema hang.
A small reproduction:

```python
from sqlalchemy import create_engine, inspect

e = create_engine("sqlite://")
with e.begin() as c:
    c.exec_driver_sql(
        "CREATE TABLE t (x INTEGER" + " " * 1000 + "NOT NULL, "
        "y INTEGER NOT NULL UNIQUE)"
    )
inspect(e).get_unique_constraints("t")   # ~11s before, instant after
```

The fix tokenises the inter-keyword whitespace with disjoint classes,
`[\t ]+[a-z0-9_]+(?:[\t ]+[a-z0-9_]+)*?[\t ]+UNIQUE`, so a space only ever
belongs to a separator and never to a token. Valid inline `UNIQUE` columns
reflect exactly as before; I have added a regression test that reflects a
table carrying a long whitespace run and runs in linear time on the new
pattern.

### Checklist

This pull request is:

- [x] A short code fix

Closes: #13398
Pull-request: https://github.com/sqlalchemy/sqlalchemy/pull/13398
Pull-request-sha: e398806f1243897f25c6ef0697200a49df464243

Change-Id: I7e394357162c9d3aad6c7a72d9276321d404ca64
(cherry picked from commit 121a17bb32046b980f120abca4793aad116aa23c)

doc/build/changelog/unreleased_20/13419.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/sqlite/base.py
test/dialect/sqlite/test_reflection.py

diff --git a/doc/build/changelog/unreleased_20/13419.rst b/doc/build/changelog/unreleased_20/13419.rst
new file mode 100644 (file)
index 0000000..8fba8c2
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: bug, sqlite
+    :tickets: 13419
+
+    Reworked the regular expression that detects inline ``UNIQUE`` column
+    constraints during SQLite ``CREATE TABLE`` reflection so that the
+    whitespace separating a column's type from a following clause is matched
+    unambiguously.  The previous pattern had three overlapping quantifiers
+    that could each consume a space character, so a column definition
+    carrying a long run of whitespace in the stored schema made
+    :meth:`_reflection.Inspector.get_unique_constraints` spend cubic time
+    backtracking before returning.  Fix courtesy of Javid Khan.
index 11c69ef218f4a295ae427cb4b3a3e513c65634f2..abd93664802d8fb900d15d68322f57d3e45b5f9f 100644 (file)
@@ -2774,7 +2774,7 @@ class SQLiteDialect(default.DefaultDialect):
             )
             INLINE_UNIQUE_PATTERN = (
                 r'(?:(".+?")|(?:[\[`])?([a-z0-9_]+)(?:[\]`])?)[\t ]'
-                r"+[a-z0-9_ ]+?[\t ]+UNIQUE"
+                r"+[a-z0-9_]+(?:[\t ]+[a-z0-9_]+)*?[\t ]+UNIQUE"
             )
 
             for match in re.finditer(UNIQUE_PATTERN, table_data, re.I):
index 804116f3fe6b60f985d32254f2c04619927066ed..53d8772ddd426d8bad3fb6a9462e86f58786bfb9 100644 (file)
@@ -872,6 +872,23 @@ class ConstraintReflectionTest(fixtures.TestBase):
             [{"column_names": ["some STUPID n,ame"], "name": None}],
         )
 
+    def test_unique_constraint_inline_whitespace_run(
+        self, connection, metadata
+    ):
+        # a long run of whitespace between a column type and a following
+        # clause used to make the inline-UNIQUE regex backtrack
+        # catastrophically while scanning the stored CREATE TABLE text;
+        # the ambiguous pattern was cubic on the whitespace length
+        Table("t", metadata, Column("x", Integer), Column("y", Integer))
+        connection.exec_driver_sql(
+            "CREATE TABLE t (x INTEGER" + (" " * 1000) + "NOT NULL, "
+            "y INTEGER NOT NULL UNIQUE)"
+        )
+        eq_(
+            inspect(connection).get_unique_constraints("t"),
+            [{"column_names": ["y"], "name": None}],
+        )
+
     def test_unique_constraint_unnamed_normal(self):
         inspector = inspect(testing.db)
         eq_(