@@ -46,12 +46,7 @@ impl CompileContext {
4646
4747 /// Substitute known variables in a SQL expression.
4848 fn substitute ( & self , sql : & str ) -> String {
49- let mut result = sql. to_string ( ) ;
50- for ( name, expr) in & self . variables {
51- // Replace whole-word occurrences of the variable name.
52- result = replace_identifier ( & result, name, & format ! ( "({expr})" ) ) ;
53- }
54- result
49+ substitute_identifiers ( sql, & self . variables )
5550 }
5651}
5752
@@ -82,11 +77,11 @@ fn compile_statements(
8277 Some ( e) => ctx. substitute ( & e. sql ) ,
8378 None => "NULL" . into ( ) ,
8479 } ;
85- ctx. variables . insert ( name. clone ( ) , expr) ;
80+ ctx. variables . insert ( name. to_lowercase ( ) , expr) ;
8681 }
8782 Statement :: Assign { target, expr } => {
8883 let substituted = ctx. substitute ( & expr. sql ) ;
89- ctx. variables . insert ( target. clone ( ) , substituted) ;
84+ ctx. variables . insert ( target. to_lowercase ( ) , substituted) ;
9085 }
9186 Statement :: If {
9287 condition,
@@ -240,7 +235,9 @@ fn compile_for(
240235 let mut case_parts = Vec :: new ( ) ;
241236 for val in & iterations {
242237 let mut iter_ctx = ctx. clone_vars ( ) ;
243- iter_ctx. variables . insert ( var. to_string ( ) , val. to_string ( ) ) ;
238+ iter_ctx
239+ . variables
240+ . insert ( var. to_lowercase ( ) , val. to_string ( ) ) ;
244241 let body_sql = compile_statements ( body, & mut iter_ctx) ?;
245242 // Each iteration becomes a WHEN clause. If the body is unconditional
246243 // (always returns), it's `WHEN true THEN <body>`. If conditional,
@@ -272,70 +269,125 @@ impl CompileContext {
272269 }
273270}
274271
275- /// Replace whole-word occurrences of `name` in `sql` with `replacement`.
276- ///
277- /// Only replaces when `name` is surrounded by non-alphanumeric characters
278- /// (or at string boundaries). This prevents replacing "x" in "max" or "text".
279- fn replace_identifier ( sql : & str , name : & str , replacement : & str ) -> String {
280- if name. is_empty ( ) || !sql. contains ( name) {
281- return sql. to_string ( ) ;
282- }
283-
284- let mut result = String :: with_capacity ( sql. len ( ) ) ;
285- let bytes = sql. as_bytes ( ) ;
286- let name_bytes = name. as_bytes ( ) ;
287- let name_len = name_bytes. len ( ) ;
288- let mut i = 0 ;
289-
290- while i < bytes. len ( ) {
291- // Skip string literals.
292- if bytes[ i] == b'\'' {
293- result. push ( '\'' ) ;
294- i += 1 ;
295- while i < bytes. len ( ) {
296- if bytes[ i] == b'\'' {
297- result. push ( '\'' ) ;
298- i += 1 ;
299- if i < bytes. len ( ) && bytes[ i] == b'\'' {
300- result. push ( '\'' ) ;
301- i += 1 ;
302- } else {
303- break ;
304- }
272+ /// Substitute all known variables in one lexical pass. Replacement text is
273+ /// copied, never revisited, so binding expansion is deterministic.
274+ fn substitute_identifiers ( sql : & str , variables : & HashMap < String , String > ) -> String {
275+ let mut output = String :: with_capacity ( sql. len ( ) ) ;
276+ let mut cursor = 0 ;
277+ while cursor < sql. len ( ) {
278+ let end = opaque_end ( sql, cursor) ;
279+ if end > cursor {
280+ output. push_str ( & sql[ cursor..end] ) ;
281+ cursor = end;
282+ } else if let Some ( ch) = sql[ cursor..] . chars ( ) . next ( ) {
283+ if is_identifier_start ( ch) {
284+ let end = consume_identifier ( sql, cursor) ;
285+ let token = & sql[ cursor..end] ;
286+ if let Some ( value) = variables. get ( & token. to_lowercase ( ) ) {
287+ output. push ( '(' ) ;
288+ output. push_str ( value) ;
289+ output. push ( ')' ) ;
305290 } else {
306- result. push ( bytes[ i] as char ) ;
307- i += 1 ;
291+ output. push_str ( token) ;
308292 }
293+ cursor = end;
294+ } else {
295+ output. push ( ch) ;
296+ cursor += ch. len_utf8 ( ) ;
309297 }
310- continue ;
311298 }
299+ }
300+ output
301+ }
302+
303+ fn opaque_end ( sql : & str , start : usize ) -> usize {
304+ let bytes = sql. as_bytes ( ) ;
305+ match bytes[ start] {
306+ b'\'' | b'"' => consume_quoted ( sql, start, bytes[ start] ) ,
307+ b'-' if bytes. get ( start + 1 ) == Some ( & b'-' ) => sql[ start..]
308+ . find ( '\n' )
309+ . map_or ( sql. len ( ) , |offset| start + offset) ,
310+ b'/' if bytes. get ( start + 1 ) == Some ( & b'*' ) => consume_block_comment ( sql, start) ,
311+ b'$' => consume_dollar_quote ( sql, start) . unwrap_or ( start) ,
312+ _ => start,
313+ }
314+ }
312315
313- // Check for whole-word match.
314- if i + name_len <= bytes. len ( )
315- && sql[ i..i + name_len] . eq_ignore_ascii_case ( name)
316- && !is_ident_char ( bytes. get ( i. wrapping_sub ( 1 ) ) . copied ( ) , i == 0 )
317- && !is_ident_continue ( bytes. get ( i + name_len) . copied ( ) )
318- {
319- result. push_str ( replacement) ;
320- i += name_len;
316+ fn consume_quoted ( sql : & str , start : usize , quote : u8 ) -> usize {
317+ let bytes = sql. as_bytes ( ) ;
318+ let mut cursor = start + 1 ;
319+ while cursor < bytes. len ( ) {
320+ if bytes[ cursor] == quote {
321+ cursor += 1 ;
322+ if bytes. get ( cursor) != Some ( & quote) {
323+ return cursor;
324+ }
325+ cursor += 1 ;
321326 } else {
322- result. push ( bytes[ i] as char ) ;
323- i += 1 ;
327+ cursor += sql[ cursor..] . chars ( ) . next ( ) . map_or ( 1 , char:: len_utf8) ;
324328 }
325329 }
330+ sql. len ( )
331+ }
326332
327- result
333+ fn consume_block_comment ( sql : & str , start : usize ) -> usize {
334+ let bytes = sql. as_bytes ( ) ;
335+ let mut cursor = start + 2 ;
336+ let mut depth = 1usize ;
337+ while cursor + 1 < bytes. len ( ) {
338+ match ( bytes[ cursor] , bytes[ cursor + 1 ] ) {
339+ ( b'/' , b'*' ) => {
340+ depth += 1 ;
341+ cursor += 2 ;
342+ }
343+ ( b'*' , b'/' ) => {
344+ depth -= 1 ;
345+ cursor += 2 ;
346+ if depth == 0 {
347+ return cursor;
348+ }
349+ }
350+ _ => cursor += sql[ cursor..] . chars ( ) . next ( ) . map_or ( 1 , char:: len_utf8) ,
351+ }
352+ }
353+ sql. len ( )
328354}
329355
330- fn is_ident_char ( byte : Option < u8 > , at_start : bool ) -> bool {
331- if at_start {
332- return false ;
356+ fn consume_dollar_quote ( sql : & str , start : usize ) -> Option < usize > {
357+ let rest = & sql[ start..] ;
358+ let close = rest[ 1 ..] . find ( '$' ) ? + 1 ;
359+ let tag = & rest[ ..=close] ;
360+ let tag_body = & tag[ 1 ..tag. len ( ) - 1 ] ;
361+ if !tag_body. is_empty ( )
362+ && ( !tag_body
363+ . chars ( )
364+ . next ( )
365+ . is_some_and ( |ch| ch == '_' || ch. is_alphabetic ( ) )
366+ || !tag_body. chars ( ) . all ( |ch| ch == '_' || ch. is_alphanumeric ( ) ) )
367+ {
368+ return None ;
333369 }
334- byte. is_some_and ( |b| b. is_ascii_alphanumeric ( ) || b == b'_' )
370+ let body_start = start + tag. len ( ) ;
371+ Some (
372+ sql[ body_start..]
373+ . find ( tag)
374+ . map_or ( sql. len ( ) , |offset| body_start + offset + tag. len ( ) ) ,
375+ )
335376}
336377
337- fn is_ident_continue ( byte : Option < u8 > ) -> bool {
338- byte. is_some_and ( |b| b. is_ascii_alphanumeric ( ) || b == b'_' )
378+ fn is_identifier_start ( ch : char ) -> bool {
379+ ch == '_' || ch. is_alphabetic ( )
380+ }
381+ fn is_identifier_continue ( ch : char ) -> bool {
382+ ch == '_' || ch == '$' || ch. is_alphanumeric ( )
383+ }
384+ fn consume_identifier ( sql : & str , start : usize ) -> usize {
385+ sql[ start..]
386+ . char_indices ( )
387+ . find_map ( |( offset, ch) | {
388+ ( offset > 0 && !is_identifier_continue ( ch) ) . then_some ( start + offset)
389+ } )
390+ . unwrap_or ( sql. len ( ) )
339391}
340392
341393#[ cfg( test) ]
@@ -472,11 +524,33 @@ mod tests {
472524 }
473525
474526 #[ test]
475- fn replace_whole_word ( ) {
476- assert_eq ! ( replace_identifier( "x + 1" , "x" , "(5)" ) , "(5) + 1" ) ;
477- assert_eq ! ( replace_identifier( "max(x)" , "x" , "(5)" ) , "max((5))" ) ;
478- // Should NOT replace "x" inside "text" or "max".
479- assert_eq ! ( replace_identifier( "text" , "x" , "(5)" ) , "text" ) ;
527+ fn replaces_only_whole_identifier_tokens ( ) {
528+ let variables = HashMap :: from ( [ ( "x" . to_string ( ) , "5" . to_string ( ) ) ] ) ;
529+ assert_eq ! ( substitute_identifiers( "x + 1" , & variables) , "(5) + 1" ) ;
530+ assert_eq ! ( substitute_identifiers( "max(x)" , & variables) , "max((5))" ) ;
531+ assert_eq ! ( substitute_identifiers( "text" , & variables) , "text" ) ;
532+ }
533+
534+ #[ test]
535+ fn replacement_is_one_pass_and_respects_unicode_dollar_boundaries ( ) {
536+ let mut variables = HashMap :: new ( ) ;
537+ variables. insert ( "x" . into ( ) , "y" . into ( ) ) ;
538+ variables. insert ( "y" . into ( ) , "9" . into ( ) ) ;
539+ variables. insert ( "é" . into ( ) , "11" . into ( ) ) ;
540+ assert_eq ! (
541+ substitute_identifiers( "x y αx xβ x$ É é" , & variables) ,
542+ "(y) (9) αx xβ x$ (11) (11)"
543+ ) ;
544+ }
545+
546+ #[ test]
547+ fn replacement_preserves_unicode_and_opaque_sql_regions ( ) {
548+ let variables = HashMap :: from ( [ ( "x" . to_string ( ) , "5" . to_string ( ) ) ] ) ;
549+ let sql = "x + '雪x' + \" x\" -- x 雪\n /* outer x /* inner x */ 雪 */ + x" ;
550+ assert_eq ! (
551+ substitute_identifiers( sql, & variables) ,
552+ "(5) + '雪x' + \" x\" -- x 雪\n /* outer x /* inner x */ 雪 */ + (5)"
553+ ) ;
480554 }
481555
482556 #[ test]
0 commit comments