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)
--- /dev/null
+.. 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.
)
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):
[{"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_(