@@ -341,3 +341,259 @@ def pandas_view(inputs: pd.DataFrame) -> pd.DataFrame:
341341 ),
342342 ):
343343 store .apply ([request_source , pandas_view ])
344+
345+
346+ def test_odfv_udf_does_not_receive_undeclared_source_columns ():
347+ """#6158: a pandas ODFV's UDF should only see its own source columns, not
348+ features from another feature view that was requested too."""
349+ with tempfile .TemporaryDirectory () as data_dir :
350+ store = FeatureStore (
351+ config = RepoConfig (
352+ project = "test_odfv_source_isolation" ,
353+ registry = os .path .join (data_dir , "registry.db" ),
354+ provider = "local" ,
355+ entity_key_serialization_version = 3 ,
356+ online_store = SqliteOnlineStoreConfig (
357+ path = os .path .join (data_dir , "online.db" )
358+ ),
359+ )
360+ )
361+
362+ end_date = datetime .now ().replace (microsecond = 0 , second = 0 , minute = 0 )
363+ start_date = end_date - timedelta (days = 15 )
364+ driver_df = create_driver_hourly_stats_df ([1001 ], start_date , end_date )
365+ driver_stats_path = os .path .join (data_dir , "driver_stats.parquet" )
366+ driver_df .to_parquet (path = driver_stats_path , allow_truncated_timestamps = True )
367+
368+ driver = Entity (name = "driver" , join_keys = ["driver_id" ])
369+ source = FileSource (
370+ name = "driver_hourly_stats_source" ,
371+ path = driver_stats_path ,
372+ timestamp_field = "event_timestamp" ,
373+ created_timestamp_column = "created" ,
374+ )
375+
376+ # Two feature views from the same source. The ODFV declares only fv_declared.
377+ fv_declared = FeatureView (
378+ name = "fv_declared" ,
379+ entities = [driver ],
380+ ttl = timedelta (days = 0 ),
381+ schema = [Field (name = "conv_rate" , dtype = Float32 )],
382+ online = True ,
383+ source = source ,
384+ )
385+ fv_undeclared = FeatureView (
386+ name = "fv_undeclared" ,
387+ entities = [driver ],
388+ ttl = timedelta (days = 0 ),
389+ schema = [Field (name = "avg_daily_trips" , dtype = Int64 )],
390+ online = True ,
391+ source = source ,
392+ )
393+
394+ @on_demand_feature_view (
395+ sources = [fv_declared ],
396+ schema = [
397+ Field (name = "saw_undeclared" , dtype = Bool ),
398+ Field (name = "saw_declared" , dtype = Bool ),
399+ Field (name = "saw_join_key" , dtype = Bool ),
400+ ],
401+ mode = "pandas" ,
402+ )
403+ def guard_view (inputs : pd .DataFrame ) -> pd .DataFrame :
404+ out = pd .DataFrame ()
405+ n = range (len (inputs ))
406+ out ["saw_undeclared" ] = ["avg_daily_trips" in inputs .columns for _ in n ]
407+ out ["saw_declared" ] = ["conv_rate" in inputs .columns for _ in n ]
408+ out ["saw_join_key" ] = ["driver_id" in inputs .columns for _ in n ]
409+ return out
410+
411+ store .apply ([driver , source , fv_declared , fv_undeclared , guard_view ])
412+ store .write_to_online_store (feature_view_name = "fv_declared" , df = driver_df )
413+ store .write_to_online_store (feature_view_name = "fv_undeclared" , df = driver_df )
414+
415+ response = store .get_online_features (
416+ entity_rows = [{"driver_id" : 1001 }],
417+ features = [
418+ "fv_declared:conv_rate" ,
419+ "fv_undeclared:avg_daily_trips" ,
420+ "guard_view:saw_undeclared" ,
421+ "guard_view:saw_declared" ,
422+ "guard_view:saw_join_key" ,
423+ ],
424+ ).to_dict ()
425+
426+ # the other FV's column should be hidden; ours and the join key should not
427+ assert response ["saw_undeclared" ] == [False ]
428+ assert response ["saw_declared" ] == [True ]
429+ assert response ["saw_join_key" ] == [True ]
430+
431+
432+ def _two_fv_store (data_dir ):
433+ store = FeatureStore (
434+ config = RepoConfig (
435+ project = "test_odfv_source_isolation" ,
436+ registry = os .path .join (data_dir , "registry.db" ),
437+ provider = "local" ,
438+ entity_key_serialization_version = 3 ,
439+ online_store = SqliteOnlineStoreConfig (
440+ path = os .path .join (data_dir , "online.db" )
441+ ),
442+ )
443+ )
444+ end_date = datetime .now ().replace (microsecond = 0 , second = 0 , minute = 0 )
445+ start_date = end_date - timedelta (days = 15 )
446+ driver_df = create_driver_hourly_stats_df ([1001 ], start_date , end_date )
447+ path = os .path .join (data_dir , "driver_stats.parquet" )
448+ driver_df .to_parquet (path = path , allow_truncated_timestamps = True )
449+ driver = Entity (name = "driver" , join_keys = ["driver_id" ])
450+ src = FileSource (
451+ name = "driver_hourly_stats_source" ,
452+ path = path ,
453+ timestamp_field = "event_timestamp" ,
454+ created_timestamp_column = "created" ,
455+ )
456+ fv1 = FeatureView (
457+ name = "fv1" ,
458+ entities = [driver ],
459+ ttl = timedelta (days = 0 ),
460+ schema = [Field (name = "conv_rate" , dtype = Float32 )],
461+ online = True ,
462+ source = src ,
463+ )
464+ fv2 = FeatureView (
465+ name = "fv2" ,
466+ entities = [driver ],
467+ ttl = timedelta (days = 0 ),
468+ schema = [Field (name = "avg_daily_trips" , dtype = Int64 )],
469+ online = True ,
470+ source = src ,
471+ )
472+ return store , driver , src , fv1 , fv2 , driver_df
473+
474+
475+ def test_odfv_python_mode_does_not_receive_undeclared_source_columns ():
476+ """Same as above but for python mode, where the UDF gets a dict (#6158)."""
477+ with tempfile .TemporaryDirectory () as data_dir :
478+ store , driver , src , fv1 , fv2 , driver_df = _two_fv_store (data_dir )
479+
480+ @on_demand_feature_view (
481+ sources = [fv1 ],
482+ schema = [
483+ Field (name = "saw_undeclared" , dtype = Bool ),
484+ Field (name = "saw_declared" , dtype = Bool ),
485+ ],
486+ mode = "python" ,
487+ )
488+ def guard_py (inputs : dict ) -> dict :
489+ return {
490+ "saw_undeclared" : ["avg_daily_trips" in inputs ],
491+ "saw_declared" : ["conv_rate" in inputs ],
492+ }
493+
494+ store .apply ([driver , src , fv1 , fv2 , guard_py ])
495+ store .write_to_online_store (feature_view_name = "fv1" , df = driver_df )
496+ store .write_to_online_store (feature_view_name = "fv2" , df = driver_df )
497+
498+ response = store .get_online_features (
499+ entity_rows = [{"driver_id" : 1001 }],
500+ features = [
501+ "fv1:conv_rate" ,
502+ "fv2:avg_daily_trips" ,
503+ "guard_py:saw_undeclared" ,
504+ "guard_py:saw_declared" ,
505+ ],
506+ ).to_dict ()
507+
508+ assert response ["saw_undeclared" ] == [False ]
509+ assert response ["saw_declared" ] == [True ]
510+
511+
512+ def test_odfv_does_not_see_other_requested_odfv_source_columns ():
513+ """#6158: one ODFV's UDF should not see another ODFV's source column, even
514+ when that feature view is only in the response as the other ODFV's input."""
515+ with tempfile .TemporaryDirectory () as data_dir :
516+ store , driver , src , fv1 , fv2 , driver_df = _two_fv_store (data_dir )
517+
518+ @on_demand_feature_view (
519+ sources = [fv1 ],
520+ schema = [Field (name = "a_saw_fv2" , dtype = Bool )],
521+ mode = "pandas" ,
522+ )
523+ def odfv_a (inputs : pd .DataFrame ) -> pd .DataFrame :
524+ out = pd .DataFrame ()
525+ out ["a_saw_fv2" ] = [
526+ "avg_daily_trips" in inputs .columns for _ in range (len (inputs ))
527+ ]
528+ return out
529+
530+ @on_demand_feature_view (
531+ sources = [fv2 ],
532+ schema = [Field (name = "b_out" , dtype = Int64 )],
533+ mode = "pandas" ,
534+ )
535+ def odfv_b (inputs : pd .DataFrame ) -> pd .DataFrame :
536+ out = pd .DataFrame ()
537+ out ["b_out" ] = list (inputs ["avg_daily_trips" ])
538+ return out
539+
540+ store .apply ([driver , src , fv1 , fv2 , odfv_a , odfv_b ])
541+ store .write_to_online_store (feature_view_name = "fv1" , df = driver_df )
542+ store .write_to_online_store (feature_view_name = "fv2" , df = driver_df )
543+
544+ # ask for just the two ODFVs - fv2 is only here as odfv_b's input
545+ response = store .get_online_features (
546+ entity_rows = [{"driver_id" : 1001 }],
547+ features = ["odfv_a:a_saw_fv2" , "odfv_b:b_out" ],
548+ ).to_dict ()
549+
550+ # odfv_a shouldn't see fv2's column; odfv_b still uses it fine
551+ assert response ["a_saw_fv2" ] == [False ]
552+ assert response ["b_out" ] is not None
553+
554+
555+ def test_odfv_udf_receives_aliased_declared_source_columns ():
556+ """A declared source referenced under an alias (``with_name``) must still reach
557+ the UDF. The isolation filter keys columns by ``projection.name``, which is what
558+ the retrieval path emits regardless of the alias, so the aliased source's feature
559+ is retained (not dropped) while an unrelated feature view stays hidden (#6158)."""
560+ with tempfile .TemporaryDirectory () as data_dir :
561+ store , driver , src , fv1 , fv2 , driver_df = _two_fv_store (data_dir )
562+
563+ # ODFV declares fv1 *under an alias*; fv2 is requested but undeclared.
564+ aliased_source = fv1 .with_name ("aliased_fv1" )
565+ assert aliased_source .projection .name == "fv1"
566+ assert aliased_source .projection .name_to_use () == "aliased_fv1"
567+
568+ @on_demand_feature_view (
569+ sources = [aliased_source ],
570+ schema = [
571+ Field (name = "saw_declared" , dtype = Bool ),
572+ Field (name = "saw_undeclared" , dtype = Bool ),
573+ ],
574+ mode = "pandas" ,
575+ )
576+ def aliased_guard (inputs : pd .DataFrame ) -> pd .DataFrame :
577+ out = pd .DataFrame ()
578+ n = range (len (inputs ))
579+ out ["saw_declared" ] = ["conv_rate" in inputs .columns for _ in n ]
580+ out ["saw_undeclared" ] = ["avg_daily_trips" in inputs .columns for _ in n ]
581+ return out
582+
583+ store .apply ([driver , src , fv1 , fv2 , aliased_guard ])
584+ store .write_to_online_store (feature_view_name = "fv1" , df = driver_df )
585+ store .write_to_online_store (feature_view_name = "fv2" , df = driver_df )
586+
587+ response = store .get_online_features (
588+ entity_rows = [{"driver_id" : 1001 }],
589+ features = [
590+ "fv1:conv_rate" ,
591+ "fv2:avg_daily_trips" ,
592+ "aliased_guard:saw_declared" ,
593+ "aliased_guard:saw_undeclared" ,
594+ ],
595+ ).to_dict ()
596+
597+ # the aliased-but-declared source must survive; the unrelated FV stays hidden
598+ assert response ["saw_declared" ] == [True ]
599+ assert response ["saw_undeclared" ] == [False ]
0 commit comments