@@ -516,6 +516,93 @@ TEST_F(OperatorTests, HashJoinLeft) {
516516 join->close ();
517517}
518518
519+ TEST_F (OperatorTests, HashJoinLeftUnmatchedCollection) {
520+ // Test that get_unmatched_left_rows/keys correctly tracks unmatched left tuples
521+ // Left table: values 1, 2, 3 (only 2 has a match)
522+ Schema left_schema = make_schema ({{" id" , common::ValueType::TYPE_INT64}});
523+ std::vector<Tuple> left_data;
524+ left_data.push_back (make_tuple ({common::Value::make_int64 (1 )})); // no match
525+ left_data.push_back (make_tuple ({common::Value::make_int64 (2 )})); // matches
526+ left_data.push_back (make_tuple ({common::Value::make_int64 (3 )})); // no match
527+
528+ // Right table: values 2, 4
529+ Schema right_schema = make_schema ({{" id" , common::ValueType::TYPE_INT64}});
530+ std::vector<Tuple> right_data;
531+ right_data.push_back (make_tuple ({common::Value::make_int64 (2 )}));
532+ right_data.push_back (make_tuple ({common::Value::make_int64 (4 )}));
533+
534+ auto left_scan = make_buffer_scan (" left_table" , left_data, left_schema);
535+ auto right_scan = make_buffer_scan (" right_table" , right_data, right_schema);
536+
537+ auto join = make_hash_join (std::move (left_scan), std::move (right_scan), col_expr (" id" ),
538+ col_expr (" id" ), JoinType::Left);
539+
540+ ASSERT_TRUE (join->init ());
541+ ASSERT_TRUE (join->open ());
542+
543+ // Consume all join results
544+ Tuple tuple;
545+ while (join->next (tuple)) {
546+ }
547+
548+ // After join completes, verify unmatched left tracking
549+ auto unmatched_rows = join->get_unmatched_left_rows ();
550+ auto unmatched_keys = join->get_unmatched_left_keys ();
551+
552+ // We expect 2 unmatched left tuples: id=1 and id=3
553+ EXPECT_EQ (unmatched_rows.size (), 2U );
554+ EXPECT_EQ (unmatched_keys.size (), 2U );
555+
556+ // Keys should be "1" and "3" (to_string of int64)
557+ EXPECT_EQ (unmatched_keys[0 ], " 1" );
558+ EXPECT_EQ (unmatched_keys[1 ], " 3" );
559+
560+ // Check the actual tuple values
561+ EXPECT_EQ (unmatched_rows[0 ].get (0 ).to_int64 (), 1 );
562+ EXPECT_EQ (unmatched_rows[1 ].get (0 ).to_int64 (), 3 );
563+
564+ join->close ();
565+ }
566+
567+ TEST_F (OperatorTests, HashJoinFullUnmatchedLeftCollection) {
568+ // Test LEFT unmatched collection for FULL join
569+ // Similar to LEFT join but tests the FULL join path
570+ Schema left_schema = make_schema ({{" id" , common::ValueType::TYPE_INT64}});
571+ std::vector<Tuple> left_data;
572+ left_data.push_back (make_tuple ({common::Value::make_int64 (1 )})); // no match
573+ left_data.push_back (make_tuple ({common::Value::make_int64 (2 )})); // matches
574+
575+ Schema right_schema = make_schema ({{" id" , common::ValueType::TYPE_INT64}});
576+ std::vector<Tuple> right_data;
577+ right_data.push_back (make_tuple ({common::Value::make_int64 (2 )}));
578+ right_data.push_back (make_tuple ({common::Value::make_int64 (3 )})); // no match
579+
580+ auto left_scan = make_buffer_scan (" left_table" , left_data, left_schema);
581+ auto right_scan = make_buffer_scan (" right_table" , right_data, right_schema);
582+
583+ auto join = make_hash_join (std::move (left_scan), std::move (right_scan), col_expr (" id" ),
584+ col_expr (" id" ), JoinType::Full);
585+
586+ ASSERT_TRUE (join->init ());
587+ ASSERT_TRUE (join->open ());
588+
589+ // Consume all join results
590+ Tuple tuple;
591+ while (join->next (tuple)) {
592+ }
593+
594+ // For FULL join, we should track unmatched LEFT tuples
595+ // Note: RIGHT unmatched tuples are emitted during right scan phase and marked matched,
596+ // so get_unmatched_right_keys() won't include them (they're already "accounted for")
597+ auto unmatched_left_keys = join->get_unmatched_left_keys ();
598+
599+ // Left unmatched: id=1
600+ EXPECT_EQ (unmatched_left_keys.size (), 1U );
601+ EXPECT_EQ (unmatched_left_keys[0 ], " 1" );
602+
603+ join->close ();
604+ }
605+
519606TEST_F (OperatorTests, HashJoinEmpty) {
520607 // Left has data
521608 Schema left_schema = make_schema ({{" id" , common::ValueType::TYPE_INT64}});
0 commit comments