@@ -309,7 +309,7 @@ def test_select_change_schema(mocker: MockerFixture, make_snapshot):
309309
310310 selector = NativeSelector (state_reader_mock , local_models )
311311
312- selected = selector .select_models (["db.parent" ], env_name )
312+ selected , _ = selector .select_models (["db.parent" ], env_name )
313313 assert selected [local_child .fqn ].render_query () != child .render_query ()
314314
315315 _assert_models_equal (
@@ -320,7 +320,7 @@ def test_select_change_schema(mocker: MockerFixture, make_snapshot):
320320 },
321321 )
322322
323- selected = selector .select_models (["db.child" ], env_name )
323+ selected , _ = selector .select_models (["db.child" ], env_name )
324324 assert selected [local_child .fqn ].data_hash == child .data_hash
325325
326326 _assert_models_equal (
@@ -343,12 +343,12 @@ def test_select_models_missing_env(mocker: MockerFixture, make_snapshot):
343343
344344 selector = NativeSelector (state_reader_mock , local_models )
345345
346- assert selector .select_models ([model .name ], "missing_env" ).keys () == {model .fqn }
347- assert not selector .select_models (["missing" ], "missing_env" )
346+ assert selector .select_models ([model .name ], "missing_env" )[ 0 ] .keys () == {model .fqn }
347+ assert not selector .select_models (["missing" ], "missing_env" )[ 0 ]
348348
349349 assert selector .select_models (
350350 [model .name ], "missing_env" , fallback_env_name = "another_missing_env"
351- ).keys () == {model .fqn }
351+ )[ 0 ] .keys () == {model .fqn }
352352
353353 state_reader_mock .get_environment .assert_has_calls (
354354 [
@@ -789,7 +789,7 @@ def test_select_models_local_tags_take_precedence_over_remote(
789789
790790 selector = NativeSelector (state_reader_mock , local_models )
791791
792- selected = selector .select_models (["tag:a" ], env_name )
792+ selected , _ = selector .select_models (["tag:a" ], env_name )
793793
794794 # both should get selected because they both now have the 'a' tag locally, even though one exists in remote state without the 'a' tag
795795 _assert_models_equal (
@@ -801,7 +801,135 @@ def test_select_models_local_tags_take_precedence_over_remote(
801801 )
802802
803803
804- def _assert_models_equal (actual : t .Dict [str , Model ], expected : t .Dict [str , Model ]) -> None :
804+ def test_select_models_returns_selected_fqns (mocker : MockerFixture , make_snapshot ):
805+ """select_models should return the set of all matched FQNs (including env-only models)
806+ alongside the model dict."""
807+ local_model = SqlModel (
808+ name = "db.local_model" ,
809+ query = d .parse_one ("SELECT 1 AS a" ),
810+ )
811+ deleted_model = SqlModel (
812+ name = "db.deleted_model" ,
813+ query = d .parse_one ("SELECT 2 AS b" ),
814+ )
815+
816+ deleted_model_snapshot = make_snapshot (deleted_model )
817+ deleted_model_snapshot .categorize_as (SnapshotChangeCategory .BREAKING )
818+
819+ env_name = "test_env"
820+
821+ state_reader_mock = mocker .Mock ()
822+ state_reader_mock .get_environment .return_value = Environment (
823+ name = env_name ,
824+ snapshots = [deleted_model_snapshot .table_info ],
825+ start_at = "2023-01-01" ,
826+ end_at = "2023-02-01" ,
827+ plan_id = "test_plan_id" ,
828+ )
829+ state_reader_mock .get_snapshots .return_value = {
830+ deleted_model_snapshot .snapshot_id : deleted_model_snapshot ,
831+ }
832+
833+ local_models : UniqueKeyDict [str , Model ] = UniqueKeyDict ("models" )
834+ local_models [local_model .fqn ] = local_model
835+
836+ selector = NativeSelector (state_reader_mock , local_models )
837+
838+ # Selecting a deleted model: selected_fqns includes it even though models dict won't.
839+ _ , selected_fqns = selector .select_models (["db.deleted_model" ], env_name )
840+ assert deleted_model .fqn in selected_fqns
841+
842+ # Selecting a local model: selected_fqns includes it.
843+ _ , selected_fqns = selector .select_models (["db.local_model" ], env_name )
844+ assert local_model .fqn in selected_fqns
845+
846+ # Mixed selection (active + deleted): both appear in selected_fqns.
847+ _ , selected_fqns = selector .select_models (["db.deleted_model" , "db.local_model" ], env_name )
848+ assert selected_fqns == {deleted_model .fqn , local_model .fqn }
849+
850+ # Wildcard should match both local and env models.
851+ _ , selected_fqns = selector .select_models (["*_model" ], env_name )
852+ assert selected_fqns == {deleted_model .fqn , local_model .fqn }
853+
854+ # Non-existent model should not appear.
855+ _ , selected_fqns = selector .select_models (["db.nonexistent" ], env_name )
856+ assert selected_fqns == set ()
857+
858+
859+ def test_select_models_selected_fqns_fallback (mocker : MockerFixture , make_snapshot ):
860+ """select_models selected_fqns should include env models found via fallback environment."""
861+ deleted_model = SqlModel (
862+ name = "db.deleted_model" ,
863+ query = d .parse_one ("SELECT 1 AS a" ),
864+ )
865+
866+ deleted_model_snapshot = make_snapshot (deleted_model )
867+ deleted_model_snapshot .categorize_as (SnapshotChangeCategory .BREAKING )
868+
869+ fallback_env = Environment (
870+ name = "prod" ,
871+ snapshots = [deleted_model_snapshot .table_info ],
872+ start_at = "2023-01-01" ,
873+ end_at = "2023-02-01" ,
874+ plan_id = "test_plan_id" ,
875+ )
876+
877+ state_reader_mock = mocker .Mock ()
878+ state_reader_mock .get_environment .side_effect = (
879+ lambda name : fallback_env if name == "prod" else None
880+ )
881+ state_reader_mock .get_snapshots .return_value = {
882+ deleted_model_snapshot .snapshot_id : deleted_model_snapshot ,
883+ }
884+
885+ local_models : UniqueKeyDict [str , Model ] = UniqueKeyDict ("models" )
886+ selector = NativeSelector (state_reader_mock , local_models )
887+
888+ _ , selected_fqns = selector .select_models (
889+ ["db.deleted_model" ], "missing_env" , fallback_env_name = "prod"
890+ )
891+ assert deleted_model .fqn in selected_fqns
892+
893+
894+ def test_select_models_selected_fqns_expired (mocker : MockerFixture , make_snapshot ):
895+ """select_models should not match env models from expired environments."""
896+ deleted_model = SqlModel (
897+ name = "db.deleted_model" ,
898+ query = d .parse_one ("SELECT 1 AS a" ),
899+ )
900+
901+ deleted_model_snapshot = make_snapshot (deleted_model )
902+ deleted_model_snapshot .categorize_as (SnapshotChangeCategory .BREAKING )
903+
904+ expired_env = Environment (
905+ name = "test_env" ,
906+ snapshots = [deleted_model_snapshot .table_info ],
907+ start_at = "2023-01-01" ,
908+ end_at = "2023-02-01" ,
909+ plan_id = "test_plan_id" ,
910+ expiration_ts = now_timestamp () - 1 ,
911+ )
912+
913+ state_reader_mock = mocker .Mock ()
914+ state_reader_mock .get_environment .return_value = expired_env
915+ state_reader_mock .get_snapshots .return_value = {
916+ deleted_model_snapshot .snapshot_id : deleted_model_snapshot ,
917+ }
918+
919+ local_models : UniqueKeyDict [str , Model ] = UniqueKeyDict ("models" )
920+ selector = NativeSelector (state_reader_mock , local_models )
921+
922+ _ , selected_fqns = selector .select_models (["db.deleted_model" ], "test_env" )
923+ assert selected_fqns == set ()
924+
925+
926+ def _assert_models_equal (
927+ actual : t .Union [t .Dict [str , Model ], t .Tuple [t .Dict [str , Model ], t .Set [str ]]],
928+ expected : t .Dict [str , Model ],
929+ ) -> None :
930+ # select_models returns a tuple; unwrap if needed.
931+ if isinstance (actual , tuple ):
932+ actual = actual [0 ]
805933 assert set (actual ) == set (expected )
806934 for name , model in actual .items ():
807935 # Use dict() to make Pydantic V2 happy.
0 commit comments