@@ -305,3 +305,164 @@ fn record_param(
305305 } ,
306306 ) ;
307307}
308+
309+ #[ cfg( test) ]
310+ mod tests {
311+ use super :: * ;
312+
313+ fn ctx ( ) -> AttrCtx {
314+ AttrCtx { by_position : HashMap :: new ( ) }
315+ }
316+
317+ fn aliases < const N : usize > ( items : [ ( & str , & str , & str ) ; N ] ) -> HashMap < String , TableRef > {
318+ items
319+ . into_iter ( )
320+ . map ( |( k, schema, table) | {
321+ ( k. to_string ( ) , TableRef { schema : schema. to_string ( ) , table : table. to_string ( ) } )
322+ } )
323+ . collect ( )
324+ }
325+
326+ #[ test]
327+ fn aliased_equal ( ) {
328+ let mut c = ctx ( ) ;
329+ let a = aliases ( [ ( "u" , "auth" , "user_account" ) ] ) ;
330+ attribute_cond ( "(u.user_id = $1)" , & a, "auth" , "user_account" , & mut c) ;
331+
332+ let p = c. by_position . get ( & 1 ) . expect ( "param 1 attributed" ) ;
333+ assert_eq ! ( p. schema, "auth" ) ;
334+ assert_eq ! ( p. table, "user_account" ) ;
335+ assert_eq ! ( p. column, "user_id" ) ;
336+ assert_eq ! ( p. confidence, "exact" ) ;
337+ }
338+
339+ // PG emits bare column names in plan text when scan is unambiguous.
340+ // Plan node pins column to its relation — exact, not a guess
341+ #[ test]
342+ fn unqualified_on_scan_is_exact ( ) {
343+ let mut c = ctx ( ) ;
344+ let a = aliases ( [ ( "session" , "auth" , "session" ) ] ) ;
345+ attribute_cond ( "(id = $1)" , & a, "auth" , "session" , & mut c) ;
346+
347+ let p = c. by_position . get ( & 1 ) . expect ( "param 1 attributed" ) ;
348+ assert_eq ! ( p. table, "session" ) ;
349+ assert_eq ! ( p. column, "id" ) ;
350+ assert_eq ! ( p. confidence, "exact" ) ;
351+ }
352+
353+ // Qualifier that doesn't resolve (outer-scope ref, schema-qualified
354+ // name, subplan name) — best-guess to current relation as heuristic
355+ #[ test]
356+ fn mismatched_qualifier_is_heuristic ( ) {
357+ let mut c = ctx ( ) ;
358+ attribute_cond ( "(outer_alias.id = $1)" , & HashMap :: new ( ) , "auth" , "session" , & mut c) ;
359+
360+ let p = c. by_position . get ( & 1 ) . expect ( "param 1 attributed" ) ;
361+ assert_eq ! ( p. confidence, "heuristic" ) ;
362+ }
363+
364+ #[ test]
365+ fn multiple_params ( ) {
366+ let mut c = ctx ( ) ;
367+ let a = aliases ( [ ( "t" , "auth" , "oauth_token" ) ] ) ;
368+ attribute_cond (
369+ "((t.access_sha = $2) AND (t.access_hash = hashtext($1)))" ,
370+ & a,
371+ "auth" ,
372+ "oauth_token" ,
373+ & mut c,
374+ ) ;
375+
376+ let p2 = c. by_position . get ( & 2 ) . expect ( "param 2 attributed" ) ;
377+ assert_eq ! ( p2. column, "access_sha" ) ;
378+ // $1 wrapped in hashtext(...) — no direct column comparison, OK
379+ // either way that the regex misses it
380+ }
381+
382+ // PG plans system views like pg_catalog.pg_settings as Function Scan.
383+ // Plan node has no "Relation Name" but carries view name in Alias —
384+ // walk_plan must still attribute conds on that node
385+ #[ test]
386+ fn function_scan_with_alias_only ( ) {
387+ let plan: serde_json:: Value = serde_json:: from_str (
388+ r#"{
389+ "Node Type": "Function Scan",
390+ "Function Name": "pg_show_all_settings",
391+ "Alias": "pg_settings",
392+ "Filter": "(name = $1)"
393+ }"# ,
394+ )
395+ . unwrap ( ) ;
396+ let mut c = ctx ( ) ;
397+ walk_plan ( & plan, "" , "" , & mut c) ;
398+
399+ let p = c. by_position . get ( & 1 ) . expect ( "param 1 attributed via Alias fallback" ) ;
400+ assert_eq ! ( p. table, "pg_settings" ) ;
401+ assert_eq ! ( p. column, "name" ) ;
402+ assert_eq ! ( p. confidence, "exact" ) ;
403+ }
404+
405+ #[ test]
406+ fn preserves_exact_over_heuristic ( ) {
407+ let mut c = ctx ( ) ;
408+ let a = aliases ( [ ( "u" , "auth" , "user_account" ) ] ) ;
409+ attribute_cond ( "(u.user_id = $1)" , & a, "" , "" , & mut c) ;
410+ // second hit that would be heuristic on different relation
411+ attribute_cond ( "(user_id = $1)" , & HashMap :: new ( ) , "public" , "other_table" , & mut c) ;
412+
413+ let p = c. by_position . get ( & 1 ) . expect ( "param 1 attributed" ) ;
414+ assert_eq ! ( p. table, "user_account" ) ;
415+ assert_eq ! ( p. confidence, "exact" ) ;
416+ }
417+
418+ // IN-list pattern: column IN ($N, $M, ...) attributes only first param
419+ #[ test]
420+ fn in_list_attributes_first_param ( ) {
421+ let mut c = ctx ( ) ;
422+ let a = aliases ( [ ( "u" , "auth" , "user_account" ) ] ) ;
423+ attribute_cond ( "(u.id IN ($3, $4, $5))" , & a, "auth" , "user_account" , & mut c) ;
424+
425+ let p = c. by_position . get ( & 3 ) . expect ( "param 3 attributed" ) ;
426+ assert_eq ! ( p. column, "id" ) ;
427+ assert_eq ! ( p. confidence, "exact" ) ;
428+ assert ! ( c. by_position. get( & 4 ) . is_none( ) ) ;
429+ }
430+
431+ // Reverse form: $N op column instead of column op $N
432+ #[ test]
433+ fn reverse_form_param_first ( ) {
434+ let mut c = ctx ( ) ;
435+ let a = aliases ( [ ( "u" , "auth" , "user_account" ) ] ) ;
436+ attribute_cond ( "($1 = u.email)" , & a, "auth" , "user_account" , & mut c) ;
437+
438+ let p = c. by_position . get ( & 1 ) . expect ( "param 1 attributed" ) ;
439+ assert_eq ! ( p. column, "email" ) ;
440+ assert_eq ! ( p. confidence, "exact" ) ;
441+ }
442+
443+ // walk_plan recurses into nested Plans
444+ #[ test]
445+ fn walk_plan_descends_into_children ( ) {
446+ let plan: serde_json:: Value = serde_json:: from_str (
447+ r#"{
448+ "Node Type": "Hash Join",
449+ "Hash Cond": "(orders.user_id = users.id)",
450+ "Plans": [
451+ {
452+ "Node Type": "Seq Scan",
453+ "Relation Name": "users",
454+ "Alias": "users",
455+ "Filter": "(email = $1)"
456+ }
457+ ]
458+ }"# ,
459+ )
460+ . unwrap ( ) ;
461+ let mut c = ctx ( ) ;
462+ walk_plan ( & plan, "" , "" , & mut c) ;
463+
464+ let p = c. by_position . get ( & 1 ) . expect ( "param 1 from child Seq Scan" ) ;
465+ assert_eq ! ( p. table, "users" ) ;
466+ assert_eq ! ( p. column, "email" ) ;
467+ }
468+ }
0 commit comments