Skip to content

Commit 7a77cba

Browse files
Fix DDL non-conditionality re SQL Server
1 parent cfb1de7 commit 7a77cba

File tree

5 files changed

+92
-7
lines changed

5 files changed

+92
-7
lines changed

cardinal_pythonlib/sqlalchemy/schema.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,11 @@ def execute_ddl(
385385
Previously we would use DDL(sql, bind=engine).execute(), but this has gone
386386
in SQLAlchemy 2.0.
387387
388-
If you want dialect-conditional execution, create the DDL object with e.g.
389-
ddl = DDL(sql).execute_if(dialect=SqlaDialectName.SQLSERVER), and pass that
390-
DDL object to this function.
388+
Note that creating the DDL object with e.g. ddl =
389+
DDL(sql).execute_if(dialect=SqlaDialectName.SQLSERVER), and passing that
390+
DDL object to this function, does NOT make execution condition; it executes
391+
regardless. The execute_if() construct is used for listeners; see
392+
https://docs.sqlalchemy.org/en/20/core/ddl.html#sqlalchemy.schema.ExecutableDDLElement.execute_if
391393
"""
392394
assert bool(sql) ^ (ddl is not None) # one or the other.
393395
if sql:

cardinal_pythonlib/sqlalchemy/sqlserver.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from sqlalchemy.schema import DDL
3434

3535
from cardinal_pythonlib.sqlalchemy.dialect import (
36+
get_dialect_name,
3637
quote_identifier,
3738
SqlaDialectName,
3839
)
@@ -49,7 +50,12 @@ def _exec_ddl_if_sqlserver(engine: Engine, sql: str) -> None:
4950
"""
5051
Execute DDL only if we are running on Microsoft SQL Server.
5152
"""
52-
ddl = DDL(sql).execute_if(dialect=SqlaDialectName.SQLSERVER)
53+
# DO NOT USE DDL(sql).execute_if(dialect=SqlaDialectName.SQLSERVER).
54+
# IT IS NOT EXECUTED CONDITIONALLY VIA THIS METHOD.
55+
dialect_name = get_dialect_name(engine)
56+
if dialect_name != SqlaDialectName.SQLSERVER:
57+
return
58+
ddl = DDL(sql)
5359
execute_ddl(engine, ddl=ddl)
5460

5561

cardinal_pythonlib/sqlalchemy/tests/schema_tests.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@
3535
from sqlalchemy.engine import create_engine
3636
from sqlalchemy.exc import NoSuchTableError, OperationalError
3737
from sqlalchemy.ext import compiler
38-
from sqlalchemy.orm import declarative_base
38+
from sqlalchemy.orm import declarative_base, Session, sessionmaker
3939
from sqlalchemy.schema import (
4040
Column,
4141
CreateTable,
42+
DDL,
4243
DDLElement,
4344
Index,
4445
MetaData,
@@ -59,6 +60,10 @@
5960
Time,
6061
)
6162

63+
from cardinal_pythonlib.sqlalchemy.engine_func import (
64+
get_dialect_name,
65+
SqlaDialectName,
66+
)
6267
from cardinal_pythonlib.sqlalchemy.schema import (
6368
add_index,
6469
column_creation_ddl,
@@ -98,6 +103,9 @@
98103
view_exists,
99104
)
100105
from cardinal_pythonlib.sqlalchemy.session import SQLITE_MEMORY_URL
106+
from cardinal_pythonlib.sqlalchemy.sqlserver import (
107+
if_sqlserver_disable_constraints,
108+
)
101109

102110
Base = declarative_base()
103111
log = logging.getLogger(__name__)
@@ -485,7 +493,7 @@ def test_mssql_transaction_count(self) -> None:
485493

486494

487495
class YetMoreSchemaTests(unittest.TestCase):
488-
def __init__(self, *args, echo: bool = False, **kwargs) -> None:
496+
def __init__(self, *args, echo: bool = True, **kwargs) -> None:
489497
self.echo = echo
490498
super().__init__(*args, **kwargs)
491499

@@ -631,6 +639,69 @@ def test_execute_ddl(self) -> None:
631639
with self.assertRaises(AssertionError):
632640
execute_ddl(self.engine) # neither
633641

642+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
643+
# Dialect conditionality for DDL
644+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
645+
@staticmethod
646+
def _present_in_log_output(_cm, msg: str) -> bool:
647+
"""
648+
Detects whether a string is present, INCLUDING AS A SUBSTRING, in
649+
log output captured from an assertLogs() context manager.
650+
"""
651+
return any(msg in line for line in _cm.output)
652+
653+
def test_ddl_dialect_conditionality_1(self) -> None:
654+
self.engine.echo = True # will write to log at INFO level
655+
656+
# 1. Check that logging capture works, and our _present_in_log_output
657+
# function.
658+
with self.assertLogs(level=logging.INFO) as cm:
659+
log.info("dummy call")
660+
self.assertTrue(self._present_in_log_output(cm, "dummy"))
661+
662+
# 2. Check our dialect is as expected: SQLite.
663+
dialect_name = get_dialect_name(self.engine)
664+
self.assertEqual(dialect_name, SqlaDialectName.SQLITE)
665+
666+
# 3. Seeing if DDL built with execute_if() will execute "directly" when
667+
# set to execute-if-SQLite. It executes - but not conditionally!
668+
ddl_yes = DDL("CREATE TABLE yesplease (a INT)").execute_if(
669+
dialect=SqlaDialectName.SQLITE
670+
)
671+
with self.assertLogs(level=logging.INFO) as cm:
672+
execute_ddl(self.engine, ddl=ddl_yes)
673+
self.assertTrue(
674+
self._present_in_log_output(cm, "CREATE TABLE yesplease")
675+
)
676+
677+
# 4. Seeing if DDL built with execute_if() will execute "directly" when
678+
# set to execute-if-MySQL. It executes - therefore not conditionally!
679+
# I'd misunderstood this: it is NOT conditionally executed.
680+
ddl_no = DDL("CREATE TABLE nothanks (a INT)").execute_if(
681+
dialect=SqlaDialectName.MYSQL
682+
)
683+
with self.assertLogs(level=logging.INFO) as cm:
684+
execute_ddl(self.engine, ddl=ddl_no)
685+
self.assertTrue(
686+
self._present_in_log_output(cm, "CREATE TABLE nothanks")
687+
)
688+
# I'd thought this would be false, but it is true.
689+
690+
def test_ddl_dialect_conditionality_2(self) -> None:
691+
# Therefore:
692+
self.engine.echo = True # will write to log at INFO level
693+
# The test above (test_ddl_dialect_conditionality_1) proves that
694+
# this code will log something if SQL is emitted.
695+
696+
session = sessionmaker(
697+
bind=self.engine, future=True
698+
)() # type: Session
699+
700+
with self.assertNoLogs(level=logging.INFO):
701+
with if_sqlserver_disable_constraints(session, tablename="person"):
702+
pass
703+
# Should do nothing, therefore emit no logs.
704+
634705

635706
class SchemaAbstractTests(unittest.TestCase):
636707
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

cardinal_pythonlib/version_string.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,5 @@
3131
3232
"""
3333

34-
VERSION_STRING = "2.0.0"
34+
VERSION_STRING = "2.0.1"
3535
# Use semantic versioning: https://semver.org/

docs/source/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,3 +846,9 @@ Quick links:
846846
was apply filters (if required) and execute.
847847

848848
- Multiple internal changes to support SQLAlchemy 2.
849+
850+
**2.0.1 (2025-01-22)**
851+
852+
- Bugfix to ``cardinal_pythonlib.sqlalchemy.sqlserver`` functions as they
853+
were executing unconditionally, regardless of SQLAlchemy dialect (they should
854+
have been conditional to SQL Server).

0 commit comments

Comments
 (0)