3434 import pandas as pd
3535
3636 from sqlglot .dialects .dialect import DialectType
37+ from sqlglot .generator import Generator
3738
3839 Row = t .Dict [str , t .Any ]
3940
4849}
4950
5051
52+ _FROZEN_TIME_GENERATORS : t .Dict [
53+ t .Tuple [t .Type [Generator ], str , t .Optional [str ]], t .Type [Generator ]
54+ ] = {}
55+ _FROZEN_TIME_GENERATORS_LOCK = threading .Lock ()
56+
57+
58+ def _frozen_time_generator_class (
59+ generator_class : t .Type [Generator ], execution_time : str , dialect : t .Optional [str ]
60+ ) -> t .Type [Generator ]:
61+ """Returns a subclass of `generator_class` whose CURRENT_* transforms render `execution_time`.
62+
63+ SQLGlot caches one dispatch table per generator class, so a subclass gets its own table and
64+ the dialect's shared generator class is never modified. Subclasses are cached per
65+ (generator, execution time, dialect) so tests that share an execution time share a table.
66+ """
67+ key = (generator_class , execution_time , dialect )
68+ with _FROZEN_TIME_GENERATORS_LOCK :
69+ klass = _FROZEN_TIME_GENERATORS .get (key )
70+ if klass is None :
71+ exec_time = exp .Literal .string (execution_time )
72+ klass = t .cast (
73+ t .Type ["Generator" ],
74+ type (
75+ f"{ generator_class .__name__ } FrozenTime" ,
76+ (generator_class ,),
77+ {
78+ "TRANSFORMS" : {
79+ ** generator_class .TRANSFORMS ,
80+ exp .CurrentDate : lambda self , _ : self .sql (
81+ exp .cast (exec_time , "date" , dialect = dialect )
82+ ),
83+ exp .CurrentDatetime : lambda self , _ : self .sql (
84+ exp .cast (exec_time , "datetime" , dialect = dialect )
85+ ),
86+ exp .CurrentTime : lambda self , _ : self .sql (
87+ exp .cast (exec_time , "time" , dialect = dialect )
88+ ),
89+ exp .CurrentTimestamp : lambda self , _ : self .sql (
90+ exp .cast (exec_time , "timestamp" , dialect = dialect )
91+ ),
92+ }
93+ },
94+ ),
95+ )
96+ _FROZEN_TIME_GENERATORS [key ] = klass
97+ return klass
98+
99+
51100class ModelTest (unittest .TestCase ):
52101 __test__ = False
53102
@@ -116,31 +165,22 @@ def __init__(
116165 )
117166 self ._qualified_fixture_schema = schema_ (self ._fixture_schema , self ._fixture_catalog )
118167
119- self ._transforms = self ._test_adapter_dialect .generator_class .TRANSFORMS
120168 self ._execution_time = str (self .body .get ("vars" , {}).get ("execution_time" ) or "" )
121169
122170 if self ._execution_time :
123171 # Normalizes the execution time by converting it into UTC timezone
124172 self ._execution_time = str (to_datetime (self ._execution_time ))
125173
126- # When execution_time is set, we mock the CURRENT_* SQL expressions so they always return it
174+ # When execution_time is set, the CURRENT_* SQL expressions must render as that time. The
175+ # overrides live on a per-test generator subclass rather than on the dialect's shared
176+ # generator class: SQLGlot caches one dispatch table per generator class, so patching the
177+ # shared one is visible to every other thread that renders SQL (e.g. a concurrent test
178+ # creating its fixture views) and races with the patch's restoration.
179+ self ._generator_class = self ._test_adapter_dialect .generator_class
127180 if self ._execution_time :
128- exec_time = exp .Literal .string (self ._execution_time )
129- self ._transforms = {
130- ** self ._transforms ,
131- exp .CurrentDate : lambda self , _ : self .sql (
132- exp .cast (exec_time , "date" , dialect = dialect )
133- ),
134- exp .CurrentDatetime : lambda self , _ : self .sql (
135- exp .cast (exec_time , "datetime" , dialect = dialect )
136- ),
137- exp .CurrentTime : lambda self , _ : self .sql (
138- exp .cast (exec_time , "time" , dialect = dialect )
139- ),
140- exp .CurrentTimestamp : lambda self , _ : self .sql (
141- exp .cast (exec_time , "timestamp" , dialect = dialect )
142- ),
143- }
181+ self ._generator_class = _frozen_time_generator_class (
182+ self ._generator_class , self ._execution_time , dialect
183+ )
144184
145185 super ().__init__ ()
146186
@@ -600,15 +640,18 @@ def _normalize_column_name(self, name: str) -> str:
600640 return normalized_name
601641
602642 @contextmanager
603- def _concurrent_render_context (self ) -> t .Iterator [None ]:
643+ def _concurrent_render_context (self , patch_shared_dialect : bool = False ) -> t .Iterator [None ]:
604644 """
605645 Context manager that ensures that the tests are executed safely in a concurrent environment.
606- This is needed in case `execution_time` is set, as we'd then have to:
607- - Freeze time through `time_machine` (not thread safe)
608- - Globally patch the SQLGlot dialect so that any date/time nodes are evaluated at the `execution_time` during generation
646+ This is needed in case `execution_time` is set, as we'd then have to freeze time through
647+ `time_machine`, which is not thread safe.
648+
649+ SQL model tests render through `self._generator_class`, so the shared dialect is never
650+ modified. Python model tests may run arbitrary SQL through the engine adapter, whose
651+ generator cannot be swapped per test, so they additionally patch the shared generator's
652+ transforms (`patch_shared_dialect=True`) while holding the lock.
609653 """
610654 import time_machine
611- from sqlglot .generator import _DISPATCH_CACHE
612655
613656 lock_ctx : AbstractContextManager = (
614657 self .CONCURRENT_RENDER_LOCK if self .concurrency else nullcontext ()
@@ -618,19 +661,30 @@ def _concurrent_render_context(self) -> t.Iterator[None]:
618661 dispatch_patch_ctx : AbstractContextManager = nullcontext ()
619662
620663 if self ._execution_time :
621- generator_class = self ._test_adapter_dialect .generator_class
622664 time_ctx = time_machine .travel (self ._execution_time , tick = False )
623- dialect_patch_ctx = patch .dict (generator_class .TRANSFORMS , self ._transforms )
665+
666+ if self ._execution_time and patch_shared_dialect :
667+ from sqlglot .generator import _DISPATCH_CACHE
668+
669+ generator_class = self ._test_adapter_dialect .generator_class
670+ transforms = self ._generator_class .TRANSFORMS
671+ dialect_patch_ctx = patch .dict (generator_class .TRANSFORMS , transforms )
624672
625673 # sqlglot caches a dispatch table per generator class, so we need to patch
626674 # it as well to ensure the overridden transforms are actually used
627675 dispatch = _DISPATCH_CACHE .get (generator_class )
628676 if dispatch is not None :
629- dispatch_patch_ctx = patch .dict (dispatch , self . _transforms )
677+ dispatch_patch_ctx = patch .dict (dispatch , transforms )
630678
631679 with lock_ctx , time_ctx , dialect_patch_ctx , dispatch_patch_ctx :
632680 yield
633681
682+ def _generate_sql (self , expression : exp .Expr ) -> str :
683+ """Generates SQL for the testing engine, rendering CURRENT_* at `execution_time` when set."""
684+ return self ._generator_class (
685+ dialect = self ._test_adapter_dialect , pretty = self .engine_adapter ._pretty_sql
686+ ).generate (expression )
687+
634688 def _execute (self , query : exp .Query | str ) -> pd .DataFrame :
635689 """Executes the given query using the testing engine adapter and returns a DataFrame."""
636690 return self .engine_adapter .fetchdf (query )
@@ -698,9 +752,7 @@ def test_ctes(self, ctes: t.Dict[str, exp.Expr], recursive: bool = False) -> Non
698752 with self ._concurrent_render_context ():
699753 # Similar to the model's query, we render the CTE query under the locked context
700754 # so that the execution (fetchdf) can continue concurrently between the threads
701- sql = cte_query .sql (
702- self ._test_adapter_dialect , pretty = self .engine_adapter ._pretty_sql
703- )
755+ sql = self ._generate_sql (cte_query )
704756
705757 actual = self ._execute (sql )
706758 expected = self ._create_df (values , columns = cte_query .named_selects , partial = partial )
@@ -712,7 +764,7 @@ def runTest(self) -> None:
712764 # Render the model's query and generate the SQL under the locked context so that
713765 # execution (fetchdf) can continue concurrently between the threads
714766 query = self ._render_model_query ()
715- sql = query . sql ( self ._test_adapter_dialect , pretty = self . engine_adapter . _pretty_sql )
767+ sql = self ._generate_sql ( query )
716768
717769 with_clause = query .args .get ("with_" )
718770
@@ -817,7 +869,7 @@ def _execute_model(self) -> pd.DataFrame:
817869 """Executes the python model and returns a DataFrame."""
818870 import pandas as pd
819871
820- with self ._concurrent_render_context ():
872+ with self ._concurrent_render_context (patch_shared_dialect = True ):
821873 variables = self .body .get ("vars" , {}).copy ()
822874 time_kwargs = {key : variables .pop (key ) for key in TIME_KWARG_KEYS if key in variables }
823875 df = next (self .model .render (context = self .context , variables = variables , ** time_kwargs ))
0 commit comments