diff --git a/go/internal/feast/model/sortedfeatureview.go b/go/internal/feast/model/sortedfeatureview.go new file mode 100644 index 00000000000..4f5cdefc69a --- /dev/null +++ b/go/internal/feast/model/sortedfeatureview.go @@ -0,0 +1,49 @@ +package model + +import ( + "github.com/feast-dev/feast/go/protos/feast/core" +) + +type SortKey struct { + FieldName string + Order string +} + +func NewSortKeyFromProto(proto *core.SortKey) *SortKey { + return &SortKey{ + FieldName: proto.GetName(), + Order: proto.GetDefaultSortOrder().String(), + } +} + +type SortedFeatureView struct { + *FeatureView + SortKeys []*SortKey +} + +func NewSortedFeatureViewFromProto(proto *core.SortedFeatureView) *SortedFeatureView { + // Create a base FeatureView using Spec fields from the proto. + baseFV := &FeatureView{ + Base: NewBaseFeatureView(proto.GetSpec().GetName(), proto.GetSpec().GetFeatures()), + Ttl: proto.GetSpec().GetTtl(), + } + + // Convert each sort key from the proto. + sortKeys := make([]*SortKey, len(proto.GetSpec().GetSortKeys())) + for i, skProto := range proto.GetSpec().GetSortKeys() { + sortKeys[i] = NewSortKeyFromProto(skProto) + } + + return &SortedFeatureView{ + FeatureView: baseFV, + SortKeys: sortKeys, + } +} + +func (sfv *SortedFeatureView) NewSortedFeatureViewFromBase(base *BaseFeatureView) *SortedFeatureView { + newFV := sfv.FeatureView.NewFeatureViewFromBase(base) + return &SortedFeatureView{ + FeatureView: newFV, + SortKeys: sfv.SortKeys, + } +} diff --git a/sdk/python/feast/base_feature_view.py b/sdk/python/feast/base_feature_view.py index 31140e28999..bf90954bc31 100644 --- a/sdk/python/feast/base_feature_view.py +++ b/sdk/python/feast/base_feature_view.py @@ -24,6 +24,9 @@ from feast.protos.feast.core.OnDemandFeatureView_pb2 import ( OnDemandFeatureView as OnDemandFeatureViewProto, ) +from feast.protos.feast.core.SortedFeatureView_pb2 import ( + SortedFeatureView as SortedFeatureViewProto, +) from feast.protos.feast.core.StreamFeatureView_pb2 import ( StreamFeatureView as StreamFeatureViewProto, ) @@ -98,7 +101,12 @@ def proto_class(self) -> Type[Message]: @abstractmethod def to_proto( self, - ) -> Union[FeatureViewProto, OnDemandFeatureViewProto, StreamFeatureViewProto]: + ) -> Union[ + FeatureViewProto, + OnDemandFeatureViewProto, + StreamFeatureViewProto, + SortedFeatureViewProto, + ]: pass @classmethod diff --git a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py index 317131f7c45..1f927e90626 100644 --- a/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py +++ b/sdk/python/feast/infra/online_stores/contrib/cassandra_online_store/cassandra_online_store.py @@ -25,7 +25,8 @@ from datetime import datetime from functools import partial from queue import Queue -from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple +from tokenize import Double +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Union from cassandra.auth import PlainTextAuthProvider from cassandra.cluster import ( @@ -40,13 +41,26 @@ from cassandra.query import BatchStatement, BatchType, PreparedStatement from pydantic import StrictFloat, StrictInt, StrictStr -from feast import Entity, FeatureView, RepoConfig +from feast import Entity, FeatureView, RepoConfig, ValueType from feast.infra.key_encoding_utils import serialize_entity_key from feast.infra.online_stores.online_store import OnlineStore +from feast.protos.feast.core.SortedFeatureView_pb2 import SortOrder from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto from feast.rate_limiter import SlidingWindowRateLimiter from feast.repo_config import FeastConfigBaseModel +from feast.sorted_feature_view import SortedFeatureView +from feast.types import ( + Bool, + Bytes, + Float32, + Float64, + Int32, + Int64, + String, + UnixTimestamp, + from_value_type, +) # Error messages E_CASSANDRA_UNEXPECTED_CONFIGURATION_CLASS = ( @@ -684,7 +698,12 @@ def _drop_table( logger.info(f"Deleting table {fqtable}.") session.execute(drop_cql) - def _create_table(self, config: RepoConfig, project: str, table: FeatureView): + def _create_table( + self, + config: RepoConfig, + project: str, + table: Union[FeatureView, SortedFeatureView], + ): """Handle the CQL (low-level) creation of a table.""" session: Session = self._get_session(config) keyspace: str = self._keyspace @@ -692,18 +711,59 @@ def _create_table(self, config: RepoConfig, project: str, table: FeatureView): fqtable = CassandraOnlineStore._fq_table_name( keyspace, project, table, table_name_version ) - create_cql = self._get_cql_statement( - config, - "create", - fqtable, - project=project, - feature_view=table.name, - ) + if isinstance(table, SortedFeatureView): + create_cql = self._build_sorted_table_cql(project, table, fqtable) + else: + create_cql = self._get_cql_statement( + config, + "create", + fqtable, + project=project, + feature_view=table.name, + ) logger.info( f"Creating table {fqtable} in keyspace {keyspace} if not exists using {create_cql}." ) session.execute(create_cql) + def _build_sorted_table_cql( + self, project: str, table: SortedFeatureView, fqtable: str + ) -> str: + """ + Build the CQL statement for creating a SortedFeatureView table with custom + entity and sort key columns. + """ + feature_columns = [ + f"{feature.name} {self._get_cql_type(feature.dtype)}" + for feature in table.features + ] + + sort_key_columns = [ + f"{sk.name} {self._get_cql_type(from_value_type(sk.value_type))}" + for sk in table.sort_keys + ] + + sort_key_orders = [ + f"{sk.name} {'ASC' if sk.default_sort_order == SortOrder.Enum.ASC else 'DESC'}" + for sk in table.sort_keys + ] + + sort_key_names = ", ".join([col.split()[0] for col in sort_key_columns]) + + feature_columns_str = ",".join(feature_columns) + + create_cql = ( + f"CREATE TABLE IF NOT EXISTS {fqtable} (\n" + f" entity_key TEXT,\n" + f" {feature_columns_str},\n" + f" event_ts TIMESTAMP,\n" + f" created_ts TIMESTAMP,\n" + f" PRIMARY KEY ((entity_key), {sort_key_names})\n" + f") WITH CLUSTERING ORDER BY ({', '.join(sort_key_orders)})\n" + f"AND COMMENT='project={project}, feature_view={table.name}';" + ) + return create_cql.strip() + def _get_cql_statement( self, config: RepoConfig, op_name: str, fqtable: str, **kwargs ): @@ -737,3 +797,23 @@ def _get_cql_statement( return self._prepared_statements[cache_key] else: return statement + + def _get_cql_type(self, value_type: ValueType) -> str: + """Map Feast value types to Cassandra CQL data types.""" + # Mapping for scalar types. + scalar_mapping = { + Bytes: "BLOB", + String: "TEXT", + Int32: "INT", + Int64: "BIGINT", + Double: "DOUBLE", + Float32: "FLOAT", + Float64: "FLOAT", + Bool: "BOOLEAN", + UnixTimestamp: "TIMESTAMP", + } + + if value_type in scalar_mapping: + return scalar_mapping[value_type] + else: + raise ValueError(f"Unsupported type: {value_type}") diff --git a/sdk/python/feast/sort_key.py b/sdk/python/feast/sort_key.py new file mode 100644 index 00000000000..0e72741fb82 --- /dev/null +++ b/sdk/python/feast/sort_key.py @@ -0,0 +1,89 @@ +import warnings +from typing import Dict, Optional, Union + +from typeguard import typechecked + +from feast.entity import Entity +from feast.protos.feast.core.SortedFeatureView_pb2 import ( + SortKey as SortKeyProto, +) +from feast.protos.feast.core.SortedFeatureView_pb2 import ( + SortOrder, +) +from feast.types import ComplexFeastType, PrimitiveFeastType +from feast.value_type import ValueType + +warnings.simplefilter("ignore", DeprecationWarning) + +# DUMMY_ENTITY is a placeholder entity used in entityless FeatureViews +DUMMY_ENTITY_ID = "__dummy_id" +DUMMY_ENTITY_NAME = "__dummy" +DUMMY_ENTITY = Entity( + name=DUMMY_ENTITY_NAME, + join_keys=[DUMMY_ENTITY_ID], +) + + +@typechecked +class SortKey: + """ + A helper class representing a sorting key for a SortedFeatureView. + """ + + name: str + value_type: ValueType + default_sort_order: SortOrder.Enum.ValueType + tags: Dict[str, str] + description: str + + def __init__( + self, + name: str, + value_type: Union[ValueType, PrimitiveFeastType, ComplexFeastType], + default_sort_order: SortOrder.Enum.ValueType = SortOrder.ASC, + tags: Optional[Dict[str, str]] = None, + description: str = "", + ): + self.name = name + if isinstance(value_type, ValueType): + self.value_type = value_type + elif isinstance(value_type, (PrimitiveFeastType, ComplexFeastType)): + self.value_type = value_type.to_value_type() + else: + raise ValueError(f"Unsupported value type: {value_type}") + self.default_sort_order = default_sort_order + self.tags = tags or {} + self.description = description + + def ensure_valid(self): + """ + Validates that the SortKey has the required fields. + """ + if not self.name: + raise ValueError("SortKey must have a non-empty name.") + if not isinstance(self.value_type, ValueType): + raise ValueError("SortKey must have a valid value_type of type ValueType.") + if self.default_sort_order not in (SortOrder.ASC, SortOrder.DESC): + raise ValueError( + "SortKey default_sort_order must be either SortOrder.ASC or SortOrder.DESC." + ) + + def to_proto(self) -> SortKeyProto: + proto = SortKeyProto( + name=self.name, + value_type=self.value_type.value, + default_sort_order=self.default_sort_order, + description=self.description, + ) + proto.tags.update(self.tags) + return proto + + @classmethod + def from_proto(cls, proto: SortKeyProto) -> "SortKey": + return cls( + name=proto.name, + value_type=ValueType(proto.value_type), + default_sort_order=proto.default_sort_order, + tags=dict(proto.tags), + description=proto.description, + ) diff --git a/sdk/python/feast/sorted_feature_view.py b/sdk/python/feast/sorted_feature_view.py new file mode 100644 index 00000000000..f16c90afda5 --- /dev/null +++ b/sdk/python/feast/sorted_feature_view.py @@ -0,0 +1,221 @@ +import copy +import warnings +from datetime import timedelta +from typing import Dict, List, Optional, Type + +from google.protobuf.message import Message +from typeguard import typechecked + +from feast import FeatureView, utils +from feast.data_source import DataSource +from feast.entity import Entity +from feast.feature_view_projection import FeatureViewProjection +from feast.field import Field +from feast.protos.feast.core.SortedFeatureView_pb2 import ( + SortedFeatureView as SortedFeatureViewProto, +) +from feast.protos.feast.core.SortedFeatureView_pb2 import ( + SortedFeatureViewSpec as SortedFeatureViewSpecProto, +) +from feast.sort_key import SortKey + +warnings.simplefilter("ignore", DeprecationWarning) + +# DUMMY_ENTITY is a placeholder entity used in entityless FeatureViews +DUMMY_ENTITY_ID = "__dummy_id" +DUMMY_ENTITY_NAME = "__dummy" +DUMMY_ENTITY = Entity( + name=DUMMY_ENTITY_NAME, + join_keys=[DUMMY_ENTITY_ID], +) +DUMMY_SORT_KEY_NAME = "__dummy_sort_key" + + +@typechecked +class SortedFeatureView(FeatureView): + """ + SortedFeatureView extends FeatureView by adding support for range queries + via sort keys. + """ + + sort_keys: List[SortKey] + + def __init__( + self, + *, + name: str, + source: DataSource, + schema: Optional[List[Field]] = None, + entities: Optional[List[Entity]] = None, + ttl: Optional[timedelta] = timedelta(days=0), + online: bool = True, + description: str = "", + tags: Optional[Dict[str, str]] = None, + owner: str = "", + sort_keys: Optional[List[SortKey]] = None, + ): + super().__init__( + name=name, + source=source, + schema=schema, + entities=entities, + ttl=ttl, + online=online, + description=description, + tags=tags, + owner=owner, + ) + self.sort_keys = sort_keys if sort_keys is not None else [] + + def __copy__(self): + sfv = SortedFeatureView( + name=self.name, + source=self.stream_source if self.stream_source else self.batch_source, + schema=self.schema, + entities=self.original_entities, + ttl=self.ttl, + online=self.online, + description=self.description, + tags=copy.deepcopy(self.tags), + owner=self.owner, + sort_keys=copy.copy(self.sort_keys), + ) + sfv.entities = self.entities + sfv.features = copy.copy(self.features) + sfv.entity_columns = copy.copy(self.entity_columns) + sfv.projection = copy.copy(self.projection) + return sfv + + def ensure_valid(self): + """ + Validates the state of this SortedFeatureView. + This includes the base FeatureView validations and ensures that at least one sort key is defined. + """ + super().ensure_valid() + + # Check that sort_keys is not empty. + if not self.sort_keys: + raise ValueError( + "SortedFeatureView must have at least one sort key defined." + ) + # check if the sort_key is not a part of the entity_columns + for sort_key in self.sort_keys: + if sort_key.name in [entity.name for entity in self.entity_columns]: + raise ValueError( + f"Sort key {sort_key.name} cannot be part of entity columns" + ) + + @property + def proto_class(self) -> Type[Message]: + return SortedFeatureViewProto + + def to_proto(self): + """ + Converts this SortedFeatureView to its protobuf representation. + """ + meta = self.to_proto_meta() + ttl_duration = self.get_ttl_duration() + + # Convert batch and stream sources. + batch_source_proto = self.batch_source.to_proto() + batch_source_proto.data_source_class_type = ( + f"{self.batch_source.__class__.__module__}." + f"{self.batch_source.__class__.__name__}" + ) + + stream_source_proto = None + if self.stream_source: + stream_source_proto = self.stream_source.to_proto() + stream_source_proto.data_source_class_type = ( + f"{self.stream_source.__class__.__module__}." + f"{self.stream_source.__class__.__name__}" + ) + + original_entities = [entity.to_proto() for entity in self.original_entities] + + spec = SortedFeatureViewSpecProto( + name=self.name, + entities=self.entities, + features=[field.to_proto() for field in self.features], + entity_columns=[field.to_proto() for field in self.entity_columns], + sort_keys=[sk.to_proto() for sk in self.sort_keys], + description=self.description, + tags=self.tags, + owner=self.owner, + ttl=(ttl_duration if ttl_duration is not None else None), + batch_source=batch_source_proto, + stream_source=stream_source_proto, + online=self.online, + original_entities=original_entities, + ) + + return SortedFeatureViewProto(spec=spec, meta=meta) + + @classmethod + def from_proto(cls, sfv_proto): + """ + Creates a SortedFeatureView from its protobuf representation. + """ + spec = sfv_proto.spec + + batch_source = DataSource.from_proto(spec.batch_source) + stream_source = ( + DataSource.from_proto(spec.stream_source) + if spec.HasField("stream_source") + else None + ) + + # Create the SortedFeatureView instance. + sorted_feature_view = cls( + name=spec.name, + description=spec.description, + tags=dict(spec.tags), + owner=spec.owner, + online=spec.online, + ttl=( + timedelta(days=0) + if spec.ttl.ToNanoseconds() == 0 + else spec.ttl.ToTimedelta() + ), + source=batch_source, + schema=None, + entities=None, + sort_keys=[SortKey.from_proto(sk) for sk in spec.sort_keys], + ) + + if stream_source: + sorted_feature_view.stream_source = stream_source + + sorted_feature_view.entities = list(spec.entities) + sorted_feature_view.original_entities = [ + Entity.from_proto(e) for e in spec.original_entities + ] + sorted_feature_view.features = [Field.from_proto(f) for f in spec.features] + sorted_feature_view.entity_columns = [ + Field.from_proto(f) for f in spec.entity_columns + ] + sorted_feature_view.original_schema = ( + sorted_feature_view.entity_columns + sorted_feature_view.features + ) + + sorted_feature_view.projection = FeatureViewProjection.from_definition( + sorted_feature_view + ) + + if sfv_proto.meta.HasField("created_timestamp"): + sorted_feature_view.created_timestamp = ( + sfv_proto.meta.created_timestamp.ToDatetime() + ) + if sfv_proto.meta.HasField("last_updated_timestamp"): + sorted_feature_view.last_updated_timestamp = ( + sfv_proto.meta.last_updated_timestamp.ToDatetime() + ) + for interval in sfv_proto.meta.materialization_intervals: + sorted_feature_view.materialization_intervals.append( + ( + utils.make_tzaware(interval.start_time.ToDatetime()), + utils.make_tzaware(interval.end_time.ToDatetime()), + ) + ) + + return sorted_feature_view diff --git a/sdk/python/tests/unit/infra/online_store/test_cassandra_online_store.py b/sdk/python/tests/unit/infra/online_store/test_cassandra_online_store.py index ba37dc4441d..e60d766f063 100644 --- a/sdk/python/tests/unit/infra/online_store/test_cassandra_online_store.py +++ b/sdk/python/tests/unit/infra/online_store/test_cassandra_online_store.py @@ -1,10 +1,15 @@ +import textwrap + import pytest -from feast import FeatureView +from feast import Entity, FeatureView, Field from feast.infra.offline_stores.file_source import FileSource from feast.infra.online_stores.contrib.cassandra_online_store.cassandra_online_store import ( CassandraOnlineStore, ) +from feast.protos.feast.core.SortedFeatureView_pb2 import SortOrder +from feast.sorted_feature_view import SortedFeatureView, SortKey +from feast.types import Int64, String @pytest.fixture @@ -13,6 +18,31 @@ def file_source(): return file_source +@pytest.fixture +def sorted_feature_view(file_source): + return SortedFeatureView( + name="test_sorted_feature_view", + entities=[Entity(name="entity1", join_keys=["entity1_id"])], + source=FileSource(name="my_file_source", path="test.parquet"), + schema=[ + Field(name="feature1", dtype=Int64), + Field(name="feature2", dtype=String), + ], + sort_keys=[ + SortKey( + name="sort_key1", + value_type=Int64, + default_sort_order=SortOrder.Enum.ASC, # use the enum value + ), + SortKey( + name="sort_key2", + value_type=String, + default_sort_order=SortOrder.Enum.DESC, + ), + ], + ) + + def test_fq_table_name_v1_within_limit(file_source): keyspace = "test_keyspace" project = "test_project" @@ -71,3 +101,26 @@ def test_fq_table_name_invalid_version(file_source): with pytest.raises(ValueError) as excinfo: CassandraOnlineStore._fq_table_name(keyspace, project, table, 3) assert "Unknown table name format version: 3" in str(excinfo.value) + + +def test_build_sorted_table_cql(sorted_feature_view): + project = "test_project" + fqtable = "test_keyspace.test_project_test_sorted_feature_view" + + expected_cql = textwrap.dedent("""\ + CREATE TABLE IF NOT EXISTS test_keyspace.test_project_test_sorted_feature_view ( + entity_key TEXT, + feature1 BIGINT,feature2 TEXT, + event_ts TIMESTAMP, + created_ts TIMESTAMP, + PRIMARY KEY ((entity_key), sort_key1, sort_key2) + ) WITH CLUSTERING ORDER BY (sort_key1 ASC, sort_key2 DESC) + AND COMMENT='project=test_project, feature_view=test_sorted_feature_view'; + """).strip() + + cassandra_online_store = CassandraOnlineStore() + actual_cql = cassandra_online_store._build_sorted_table_cql( + project, sorted_feature_view, fqtable + ) + + assert actual_cql == expected_cql diff --git a/sdk/python/tests/unit/test_sorted_feature_view.py b/sdk/python/tests/unit/test_sorted_feature_view.py new file mode 100644 index 00000000000..062972ed847 --- /dev/null +++ b/sdk/python/tests/unit/test_sorted_feature_view.py @@ -0,0 +1,188 @@ +import copy +from datetime import timedelta + +import pytest + +from feast import FileSource +from feast.entity import Entity +from feast.field import Field +from feast.protos.feast.core.SortedFeatureView_pb2 import SortOrder +from feast.sort_key import SortKey +from feast.sorted_feature_view import SortedFeatureView +from feast.types import Float32 +from feast.utils import _utc_now, make_tzaware +from feast.value_type import ValueType + + +def test_sorted_feature_view_to_proto_and_from_proto(): + """ + Test round-trip conversion: + - Create a SortedFeatureView with a sort key. + - Convert it to its proto representation. + - Convert back from proto. + - Verify that key attributes (name, description, tags, owner, sort keys, etc.) are preserved. + """ + source = FileSource(path="some path") + entity = Entity(name="entity1", join_keys=["entity1_id"]) + + sort_key = SortKey( + name="sort_key1", value_type=ValueType.INT64, default_sort_order=SortOrder.ASC + ) + + sfv = SortedFeatureView( + name="sorted_feature_view_test", + source=source, + entities=[entity], + ttl=timedelta(days=1), + sort_keys=[sort_key], + description="test sorted feature view", + tags={"test": "true"}, + owner="test_owner", + ) + + proto = sfv.to_proto() + sfv_from_proto = SortedFeatureView.from_proto(proto) + + assert sfv.name == sfv_from_proto.name + assert sfv.description == sfv_from_proto.description + assert sfv.tags == sfv_from_proto.tags + assert sfv.owner == sfv_from_proto.owner + + assert len(sfv.sort_keys) == len(sfv_from_proto.sort_keys) + for original_sk, proto_sk in zip(sfv.sort_keys, sfv_from_proto.sort_keys): + assert original_sk.name == proto_sk.name + assert original_sk.default_sort_order == proto_sk.default_sort_order + assert original_sk.value_type == proto_sk.value_type + + +def test_sorted_feature_view_ensure_valid(): + """ + Test that a SortedFeatureView without any sort keys fails validation. + """ + source = FileSource(path="some path") + entity = Entity(name="entity1", join_keys=["entity1_id"]) + + sfv = SortedFeatureView( + name="invalid_sorted_feature_view", + source=source, + entities=[entity], + sort_keys=[], + ) + + with pytest.raises(ValueError) as excinfo: + sfv.ensure_valid() + assert "must have at least one sort key defined" in str(excinfo.value) + + +def test_sorted_feature_view_ensure_valid_sort_key_in_entity_columns(): + """ + Test that a SortedFeatureView fails validation if any sort key's name is part of the entity columns. + """ + source = FileSource(path="some path") + entity = Entity(name="entity1", join_keys=["entity1_id"]) + + # Create a field that represents an entity column with the same name as the entity. + # (Assuming Field and Float32 are imported and used similarly in your code.) + entity_field = Field(name="entity1", dtype=Float32) + + # Create a sort key that conflicts with the entity column name. + sort_key = SortKey( + name="entity1", # This is the same as the entity's name / entity column. + value_type=ValueType.STRING, + default_sort_order=SortOrder.ASC, # Assuming ASC is valid. + ) + + # Create a SortedFeatureView with a sort key that conflicts. + sfv = SortedFeatureView( + name="invalid_sorted_feature_view", + source=source, + entities=[entity], + sort_keys=[sort_key], + ) + # Simulate that the entity field is recognized as an entity column. + sfv.entity_columns = [entity_field] + + with pytest.raises(ValueError) as excinfo: + sfv.ensure_valid() + assert "Sort key entity1 cannot be part of entity columns" in str(excinfo.value) + + +def test_sorted_feature_view_copy(): + """ + Test that __copy__ produces a valid and independent copy of a SortedFeatureView. + """ + source = FileSource(path="some path") + entity = Entity(name="entity1", join_keys=["entity1_id"]) + + sort_key = SortKey( + name="dummy_sort_key", + value_type=ValueType.STRING, + default_sort_order=SortOrder.ASC, + ) + + sfv = SortedFeatureView( + name="sorted_feature_view_test", + source=source, + entities=[entity], + ttl=timedelta(days=1), + sort_keys=[sort_key], + description="Test sorted feature view", + tags={"test": "true"}, + owner="test_owner", + ) + + sfv_copy = copy.copy(sfv) + # Check that the copied object's attributes match. + assert sfv.name == sfv_copy.name + assert sfv.sort_keys == sfv_copy.sort_keys + assert sfv.features == sfv_copy.features + # Check that modifying the copy does not affect the original. + sfv_copy.tags["new_key"] = "new_value" + assert "new_key" not in sfv.tags + + +def test_sorted_feature_view_materialization_intervals_update(): + """ + Test that the update_materialization_intervals method correctly updates intervals. + """ + source = FileSource(path="dummy/path") + entity = Entity(name="entity1", join_keys=["entity1_id"]) + + sfv = SortedFeatureView( + name="sorted_feature_view_test", + source=source, + entities=[entity], + ttl=timedelta(days=1), + sort_keys=[ + SortKey( + name="dummy", + value_type=ValueType.STRING, + default_sort_order=SortOrder.ASC, + ) + ], + ) + + assert len(sfv.materialization_intervals) == 0 + + # Add one interval. + current_time = _utc_now() + start_date = make_tzaware(current_time - timedelta(days=1)) + end_date = make_tzaware(current_time) + sfv.materialization_intervals.append((start_date, end_date)) + + new_sfv = SortedFeatureView( + name="sorted_feature_view_updated", + source=source, + entities=[entity], + ttl=timedelta(days=1), + sort_keys=[ + SortKey( + name="dummy", + value_type=ValueType.STRING, + default_sort_order=SortOrder.ASC, + ) + ], + ) + new_sfv.update_materialization_intervals(sfv.materialization_intervals) + assert len(new_sfv.materialization_intervals) == 1 + assert new_sfv.materialization_intervals[0] == (start_date, end_date)