@@ -95,15 +95,59 @@ def wrap_in_view_for_dryrun(sql: str) -> str:
9595 if not statements or not isinstance (statements [- 1 ], sqlglot .exp .Select ):
9696 return sql
9797
98- # Split original SQL by semicolons to preserve formatting;
99- # stripping formatting causes some query dry runs to fail
100- parts = [p for p in sql .split (";" ) if p .strip ()]
98+ # Replace CREATE TEMP FUNCTION with CREATE FUNCTION using fully qualified names
99+ # CREATE VIEW doesn't support temp functions
100+ test_project = ConfigLoader .get (
101+ "default" , "test_project" , fallback = "bigquery-etl-integration-test"
102+ )
101103
102- if len (parts ) != len (statements ):
103- return sql
104+ def replace_temp_function (match ):
105+ func_name = match .group (1 )
106+ # If function name is already qualified, keep it; otherwise add project.dataset prefix
107+ if "." not in func_name and "`" not in func_name :
108+ return f"CREATE FUNCTION `{ test_project } .tmp.{ func_name } `"
109+ else :
110+ return f"CREATE FUNCTION { func_name } "
104111
105- prefix_sql = ";\n " .join (parts [:- 1 ]) + ";" if len (parts ) > 1 else ""
106- query_sql = parts [- 1 ].strip ()
112+ sql = re .sub (
113+ r"\bCREATE\s+TEMP(?:ORARY)?\s+FUNCTION\s+([^\s(]+)" ,
114+ replace_temp_function ,
115+ sql ,
116+ flags = re .IGNORECASE ,
117+ )
118+
119+ # Single statement - just wrap it
120+ if len (statements ) == 1 :
121+ view_name = f"_dryrun_view_{ random_str (8 )} "
122+ test_project = ConfigLoader .get (
123+ "default" , "test_project" , fallback = "bigquery-etl-integration-test"
124+ )
125+ query_sql = sql .strip ().rstrip (";" )
126+ return f"CREATE VIEW `{ test_project } .tmp.{ view_name } ` AS\n { query_sql } "
127+
128+ # Multiple statements: use sqlglot tokenizer to find statement boundaries
129+ # This handles semicolons in strings and comments
130+ tokens = list (sqlglot .tokens .Tokenizer (dialect = "bigquery" ).tokenize (sql ))
131+
132+ # Find semicolon tokens that separate statements (not in strings/comments)
133+ semicolon_positions = []
134+ for token in tokens :
135+ if token .token_type == sqlglot .tokens .TokenType .SEMICOLON :
136+ semicolon_positions .append (token .end )
137+
138+ # We need (len(statements) - 1) semicolons to separate statements
139+ if len (semicolon_positions ) >= len (statements ) - 1 :
140+ # The (n-1)th semicolon separates the prefix from the last statement
141+ split_pos = semicolon_positions [len (statements ) - 2 ]
142+ prefix_sql = sql [:split_pos ].strip ()
143+ query_sql = sql [split_pos :].strip ().lstrip (";" ).strip ()
144+ else :
145+ # Fallback: regenerate prefix statements, use regenerated query
146+ prefix_statements = statements [:- 1 ]
147+ prefix_sql = ";\n " .join (
148+ stmt .sql (dialect = "bigquery" ) for stmt in prefix_statements
149+ )
150+ query_sql = statements [- 1 ].sql (dialect = "bigquery" )
107151
108152 # Wrap in view
109153 view_name = f"_dryrun_view_{ random_str (8 )} "
@@ -112,7 +156,7 @@ def wrap_in_view_for_dryrun(sql: str) -> str:
112156 )
113157 wrapped_query = f"CREATE VIEW `{ test_project } .tmp.{ view_name } ` AS\n { query_sql } "
114158
115- return f"{ prefix_sql } \n \n { wrapped_query } " if prefix_sql else wrapped_query
159+ return f"{ prefix_sql } ; \n \n { wrapped_query } "
116160
117161 except Exception as e :
118162 print (f"Warning: Failed to wrap SQL in view: { e } " )
@@ -271,11 +315,6 @@ def dry_run_result(self):
271315 else :
272316 sql = self .get_sql ()
273317
274- # Wrap the query in a CREATE VIEW for faster dry runs
275- # Skip wrapping when strip_dml=True as it's used for special analysis modes
276- if not self .strip_dml :
277- sql = wrap_in_view_for_dryrun (sql )
278-
279318 query_parameters = []
280319 scheduling_metadata = self .metadata .scheduling if self .metadata else {}
281320 if date_partition_parameter := scheduling_metadata .get (
@@ -299,6 +338,30 @@ def dry_run_result(self):
299338 )
300339 )
301340
341+ # Wrap the query in a CREATE VIEW for faster dry runs
342+ # Skip wrapping when strip_dml=True as it's used for special analysis modes
343+ if not self .strip_dml :
344+ # If query has parameters, replace them with literal values in the wrapped version
345+ # since CREATE VIEW cannot use parameterized queries
346+ sql_for_wrapping = sql
347+ if query_parameters :
348+ for param in query_parameters :
349+ param_name = f"@{ param .name } "
350+ # Convert parameter value to SQL literal
351+ if param .type_ == "DATE" :
352+ param_value = f"DATE '{ param .value } '"
353+ elif param .type_ in ("STRING" , "DATETIME" , "TIMESTAMP" ):
354+ param_value = f"'{ param .value } '"
355+ elif param .type_ == "BOOL" :
356+ param_value = str (param .value ).upper ()
357+ else :
358+ param_value = str (param .value )
359+ sql_for_wrapping = sql_for_wrapping .replace (param_name , param_value )
360+
361+ sql = wrap_in_view_for_dryrun (sql_for_wrapping )
362+
363+ # print(sql)
364+
302365 project = basename (dirname (dirname (dirname (self .sqlfile ))))
303366 dataset = basename (dirname (dirname (self .sqlfile )))
304367 try :
@@ -633,6 +696,8 @@ def validate_schema(self):
633696 strip_dml = self .strip_dml ,
634697 )
635698
699+ # print(table_schema)
700+
636701 # This check relies on the new schema being deployed to prod
637702 if not query_schema .compatible (table_schema ):
638703 click .echo (
0 commit comments