diff --git a/protos/feast/core/FeatureViewProjection.proto b/protos/feast/core/FeatureViewProjection.proto index d9c80db0b8..65937e37e1 100644 --- a/protos/feast/core/FeatureViewProjection.proto +++ b/protos/feast/core/FeatureViewProjection.proto @@ -14,6 +14,9 @@ message FeatureViewProjection { // The feature view name string feature_view_name = 1; + // Alias for feature view name + string feature_view_name_to_use = 3; + // The features of the feature view that are a part of the feature reference. repeated FeatureSpecV2 feature_columns = 2; } diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py index d4a25a7abe..cf0e40aadf 100644 --- a/sdk/python/feast/errors.py +++ b/sdk/python/feast/errors.py @@ -146,8 +146,9 @@ def __init__(self, feature_refs_collisions: List[str], full_feature_names: bool) if full_feature_names: collisions = [ref.replace(":", "__") for ref in feature_refs_collisions] error_message = ( - "To resolve this collision, please ensure that the features in question " - "have different names." + "To resolve this collision, please ensure that the feature views or their own features " + "have different names. If you're intentionally joining the same feature view twice on " + "different sets of entities, please rename one of the feature views with '.with_name'." ) else: collisions = [ref.split(":")[1] for ref in feature_refs_collisions] diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 8c93a39767..acd720943e 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -327,7 +327,7 @@ def _get_features( ) for projection in feature_service_from_registry.feature_view_projections: _feature_refs.extend( - [f"{projection.name}:{f.name}" for f in projection.features] + [f"{projection.name_to_use}:{f.name}" for f in projection.features] ) else: assert isinstance(_features, list) @@ -903,7 +903,11 @@ def get_online_features( GetOnlineFeaturesResponse(field_values=result_rows) ) return self._augment_response_with_on_demand_transforms( - _feature_refs, full_feature_names, initial_response, result_rows + _feature_refs, + all_on_demand_feature_views, + full_feature_names, + initial_response, + result_rows, ) def _populate_result_rows_from_feature_view( @@ -933,7 +937,7 @@ def _populate_result_rows_from_feature_view( if feature_data is None: for feature_name in requested_features: feature_ref = ( - f"{table.name}__{feature_name}" + f"{table.projection.name_to_use}__{feature_name}" if full_feature_names else feature_name ) @@ -943,7 +947,7 @@ def _populate_result_rows_from_feature_view( else: for feature_name in feature_data: feature_ref = ( - f"{table.name}__{feature_name}" + f"{table.projection.name_to_use}__{feature_name}" if full_feature_names else feature_name ) @@ -970,16 +974,12 @@ def _get_needed_request_data_features(self, grouped_odfv_refs) -> Set[str]: def _augment_response_with_on_demand_transforms( self, feature_refs: List[str], + odfvs: List[OnDemandFeatureView], full_feature_names: bool, initial_response: OnlineResponse, result_rows: List[GetOnlineFeaturesResponse.FieldValues], ) -> OnlineResponse: - all_on_demand_feature_views = { - view.name: view - for view in self._registry.list_on_demand_feature_views( - project=self.project, allow_cache=True - ) - } + all_on_demand_feature_views = {view.name: view for view in odfvs} all_odfv_feature_names = all_on_demand_feature_views.keys() if len(all_on_demand_feature_views) == 0: @@ -1007,7 +1007,7 @@ def _augment_response_with_on_demand_transforms( for transformed_feature in selected_subset: transformed_feature_name = ( - f"{odfv.name}__{transformed_feature}" + f"{odfv.projection.name_to_use}__{transformed_feature}" if full_feature_names else transformed_feature ) diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 9752449c59..d40773ad31 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import copy import re import warnings from datetime import datetime, timedelta @@ -204,6 +205,33 @@ def is_valid(self): if not self.entities: raise ValueError("Feature view has no entities.") + def with_name(self, name: str): + """ + Produces a copy of this FeatureView with the passed name. + + Args: + name: Name to assign to the FeatureView copy. + + Returns: + A copy of this FeatureView with the name replaced with the 'name' input. + """ + fv = FeatureView( + name=self.name, + entities=self.entities, + ttl=self.ttl, + input=self.input, + batch_source=self.batch_source, + stream_source=self.stream_source, + features=self.features, + tags=self.tags, + online=self.online, + ) + + fv.set_projection(copy.copy(self.projection)) + fv.projection.name_to_use = name + + return fv + def to_proto(self) -> FeatureViewProto: """ Converts a feature view object to its protobuf representation. diff --git a/sdk/python/feast/feature_view_projection.py b/sdk/python/feast/feature_view_projection.py index 1b2961302a..f3912ada4e 100644 --- a/sdk/python/feast/feature_view_projection.py +++ b/sdk/python/feast/feature_view_projection.py @@ -11,11 +11,12 @@ @dataclass class FeatureViewProjection: name: str + name_to_use: str features: List[Feature] def to_proto(self): feature_reference_proto = FeatureViewProjectionProto( - feature_view_name=self.name + feature_view_name=self.name, feature_view_name_to_use=self.name_to_use ) for feature in self.features: feature_reference_proto.feature_columns.append(feature.to_proto()) @@ -24,7 +25,11 @@ def to_proto(self): @staticmethod def from_proto(proto: FeatureViewProjectionProto): - ref = FeatureViewProjection(name=proto.feature_view_name, features=[]) + ref = FeatureViewProjection( + name=proto.feature_view_name, + name_to_use=proto.feature_view_name_to_use, + features=[], + ) for feature_column in proto.feature_columns: ref.features.append(Feature.from_proto(feature_column)) @@ -33,5 +38,7 @@ def from_proto(proto: FeatureViewProjectionProto): @staticmethod def from_definition(feature_grouping): return FeatureViewProjection( - name=feature_grouping.name, features=feature_grouping.features + name=feature_grouping.name, + name_to_use=feature_grouping.name, + features=feature_grouping.features, ) diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index 635ce69e8c..8790e0935d 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -128,7 +128,7 @@ def get_feature_view_query_context( created_timestamp_column = feature_view.input.created_timestamp_column context = FeatureViewQueryContext( - name=feature_view.name, + name=feature_view.projection.name_to_use, ttl=ttl_seconds, entities=join_keys, features=features, diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index d2e15199f2..76231a9887 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -186,18 +186,14 @@ def _get_requested_feature_views_to_features_dict( feature_from_ref = ref_parts[1] found = False - for feature_view_from_registry in feature_views: - if feature_view_from_registry.name == feature_view_from_ref: + for fv in feature_views: + if fv.projection.name_to_use == feature_view_from_ref: found = True - feature_views_to_feature_map[feature_view_from_registry].append( - feature_from_ref - ) - for odfv_from_registry in on_demand_feature_views: - if odfv_from_registry.name == feature_view_from_ref: + feature_views_to_feature_map[fv].append(feature_from_ref) + for odfv in on_demand_feature_views: + if odfv.projection.name_to_use == feature_view_from_ref: found = True - on_demand_feature_views_to_feature_map[odfv_from_registry].append( - feature_from_ref - ) + on_demand_feature_views_to_feature_map[odfv].append(feature_from_ref) if not found: raise ValueError(f"Could not find feature view from reference {ref}") diff --git a/sdk/python/feast/on_demand_feature_view.py b/sdk/python/feast/on_demand_feature_view.py index d97da20781..93afde326f 100644 --- a/sdk/python/feast/on_demand_feature_view.py +++ b/sdk/python/feast/on_demand_feature_view.py @@ -1,3 +1,4 @@ +import copy import functools from types import MethodType from typing import Dict, List, Union, cast @@ -68,6 +69,25 @@ def __init__( def __hash__(self) -> int: return hash((id(self), self.name)) + def with_name(self, name: str): + """ + Produces a copy of this OnDemandFeatureView with the passed name. + + Args: + name: Name to assign to the OnDemandFeatureView copy. + + Returns: + A copy of this OnDemandFeatureView with the name replaced with the 'name' input. + """ + odfv = OnDemandFeatureView( + name=self.name, features=self.features, inputs=self.inputs, udf=self.udf + ) + + odfv.set_projection(copy.copy(self.projection)) + odfv.projection.name_to_use = name + + return odfv + def to_proto(self) -> OnDemandFeatureViewProto: """ Converts an on demand feature view object to its protobuf representation. diff --git a/sdk/python/tests/unit/test_feature_validation.py b/sdk/python/tests/unit/test_feature_validation.py index 7ca0c93f38..b349eb8ea0 100644 --- a/sdk/python/tests/unit/test_feature_validation.py +++ b/sdk/python/tests/unit/test_feature_validation.py @@ -20,13 +20,13 @@ def test_feature_name_collision_on_historical_retrieval(): full_feature_names=False, ) - expected_error_message = ( - "Duplicate features named avg_daily_trips found.\n" - "To resolve this collision, either use the full feature name by setting " - "'full_feature_names=True', or ensure that the features in question have different names." - ) + expected_error_message = ( + "Duplicate features named avg_daily_trips found.\n" + "To resolve this collision, either use the full feature name by setting " + "'full_feature_names=True', or ensure that the features in question have different names." + ) - assert str(error.value) == expected_error_message + assert str(error.value) == expected_error_message # check when feature names collide and 'full_feature_names=True' with pytest.raises(FeatureNameCollisionError) as error: @@ -43,9 +43,10 @@ def test_feature_name_collision_on_historical_retrieval(): full_feature_names=True, ) - expected_error_message = ( - "Duplicate features named driver_stats__avg_daily_trips found.\n" - "To resolve this collision, please ensure that the features in question " - "have different names." - ) - assert str(error.value) == expected_error_message + expected_error_message = ( + "Duplicate features named driver_stats__avg_daily_trips found.\n" + "To resolve this collision, please ensure that the feature views or their own features " + "have different names. If you're intentionally joining the same feature view twice on " + "different sets of entities, please rename one of the feature views with '.with_name'." + ) + assert str(error.value) == expected_error_message