@@ -542,3 +542,157 @@ def test_executor_join_missing_right_sheet(tmp_path: Path):
542542 )
543543 with pytest .raises (ValueError , match = "not found" ):
544544 SharedExecutor (engine ).execute (parsed )
545+
546+
547+ def test_executor_join_null_keys_do_not_match (tmp_path : Path ):
548+ """NULL join keys must not match per SQL standard: NULL != NULL."""
549+ file_path = tmp_path / "join_null_keys.xlsx"
550+ workbook = Workbook ()
551+ left = workbook .active
552+ assert left is not None
553+ left .title = "left_t"
554+ left .append (["id" , "name" ])
555+ left .append ([1 , "Alice" ])
556+ left .append ([None , "Bob" ]) # NULL key
557+ left .append ([3 , "Charlie" ])
558+
559+ right = workbook .create_sheet ("right_t" )
560+ right .append (["id" , "role" ])
561+ right .append ([1 , "admin" ])
562+ right .append ([None , "ghost" ]) # NULL key
563+ right .append ([3 , "editor" ])
564+ workbook .save (file_path )
565+
566+ engine = OpenpyxlBackend (str (file_path ))
567+ # INNER JOIN: NULL keys should NOT match
568+ parsed = parse_sql (
569+ "SELECT a.id, a.name, b.role FROM left_t a INNER JOIN right_t b ON a.id = b.id"
570+ )
571+ results = SharedExecutor (engine ).execute (parsed )
572+ # Bob should NOT appear — NULL != NULL
573+ assert results .rows == [(1 , "Alice" , "admin" ), (3 , "Charlie" , "editor" )]
574+
575+
576+ def test_executor_left_join_null_keys_unmatched (tmp_path : Path ):
577+ """LEFT JOIN with NULL keys: left row with NULL key gets NULL-filled right columns."""
578+ file_path = tmp_path / "left_join_null_keys.xlsx"
579+ workbook = Workbook ()
580+ left = workbook .active
581+ assert left is not None
582+ left .title = "left_t"
583+ left .append (["id" , "name" ])
584+ left .append ([1 , "Alice" ])
585+ left .append ([None , "Bob" ])
586+
587+ right = workbook .create_sheet ("right_t" )
588+ right .append (["id" , "role" ])
589+ right .append ([1 , "admin" ])
590+ right .append ([None , "ghost" ])
591+ workbook .save (file_path )
592+
593+ engine = OpenpyxlBackend (str (file_path ))
594+ parsed = parse_sql (
595+ "SELECT a.id, a.name, b.role FROM left_t a LEFT JOIN right_t b ON a.id = b.id"
596+ )
597+ results = SharedExecutor (engine ).execute (parsed )
598+ # Bob (NULL key) has no match → right columns are None
599+ assert results .rows == [(1 , "Alice" , "admin" ), (None , "Bob" , None )]
600+
601+
602+ def test_executor_join_numeric_string_coercion (tmp_path : Path ):
603+ """Join key '1' (string) should match 1 (int) via numeric coercion."""
604+ file_path = tmp_path / "join_coercion.xlsx"
605+ workbook = Workbook ()
606+ left = workbook .active
607+ assert left is not None
608+ left .title = "left_t"
609+ left .append (["id" , "name" ])
610+ left .append (["1" , "Alice" ]) # string '1'
611+ left .append (["2" , "Bob" ])
612+
613+ right = workbook .create_sheet ("right_t" )
614+ right .append (["id" , "role" ])
615+ right .append ([1 , "admin" ]) # int 1
616+ right .append ([2 , "editor" ])
617+ workbook .save (file_path )
618+
619+ engine = OpenpyxlBackend (str (file_path ))
620+ parsed = parse_sql (
621+ "SELECT a.id, a.name, b.role FROM left_t a INNER JOIN right_t b ON a.id = b.id"
622+ )
623+ results = SharedExecutor (engine ).execute (parsed )
624+ assert len (results .rows ) == 2
625+ assert results .rows [0 ][1 ] == "Alice"
626+ assert results .rows [0 ][2 ] == "admin"
627+
628+
629+ def test_executor_join_unknown_select_column (tmp_path : Path ):
630+ """SELECT referencing non-existent column in JOIN query raises ValueError."""
631+ file_path = tmp_path / "join_bad_col.xlsx"
632+ workbook = Workbook ()
633+ left = workbook .active
634+ assert left is not None
635+ left .title = "users"
636+ left .append (["id" , "name" ])
637+ left .append ([1 , "Alice" ])
638+
639+ right = workbook .create_sheet ("admins" )
640+ right .append (["id" , "role" ])
641+ right .append ([1 , "admin" ])
642+ workbook .save (file_path )
643+
644+ engine = OpenpyxlBackend (str (file_path ))
645+ # a.nonexistent doesn't exist in users sheet
646+ parsed = parse_sql (
647+ "SELECT a.nonexistent FROM users a INNER JOIN admins b ON a.id = b.id"
648+ )
649+ with pytest .raises (ValueError , match = "Unknown column" ):
650+ SharedExecutor (engine ).execute (parsed )
651+
652+
653+ def test_executor_join_unknown_on_column (tmp_path : Path ):
654+ """ON referencing non-existent column raises ValueError."""
655+ file_path = tmp_path / "join_bad_on.xlsx"
656+ workbook = Workbook ()
657+ left = workbook .active
658+ assert left is not None
659+ left .title = "users"
660+ left .append (["id" , "name" ])
661+ left .append ([1 , "Alice" ])
662+
663+ right = workbook .create_sheet ("admins" )
664+ right .append (["id" , "role" ])
665+ right .append ([1 , "admin" ])
666+ workbook .save (file_path )
667+
668+ engine = OpenpyxlBackend (str (file_path ))
669+ parsed = parse_sql (
670+ "SELECT a.id, b.id FROM users a INNER JOIN admins b ON a.id = b.nonexistent"
671+ )
672+ with pytest .raises (ValueError , match = "Unknown column" ):
673+ SharedExecutor (engine ).execute (parsed )
674+
675+
676+ def test_executor_join_with_as_alias (tmp_path : Path ):
677+ """JOIN with AS keyword in alias (SQLAlchemy compatibility)."""
678+ file_path = tmp_path / "join_as_alias.xlsx"
679+ workbook = Workbook ()
680+ left = workbook .active
681+ assert left is not None
682+ left .title = "users"
683+ left .append (["id" , "name" ])
684+ left .append ([1 , "Alice" ])
685+ left .append ([2 , "Bob" ])
686+
687+ right = workbook .create_sheet ("admins" )
688+ right .append (["id" , "role" ])
689+ right .append ([1 , "admin" ])
690+ workbook .save (file_path )
691+
692+ engine = OpenpyxlBackend (str (file_path ))
693+ # Use 'AS' keyword in both FROM and JOIN aliases
694+ parsed = parse_sql (
695+ "SELECT a.id, a.name, b.role FROM users AS a INNER JOIN admins AS b ON a.id = b.id"
696+ )
697+ results = SharedExecutor (engine ).execute (parsed )
698+ assert results .rows == [(1 , "Alice" , "admin" )]
0 commit comments