Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions examples/multiple_join_keys_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Multiple Join Keys Example for Feast Entities

This example demonstrates the new multiple join keys functionality
introduced in Feast 0.55.0, which resolves the previous limitation:
"ValueError: An entity may only have a single join key"

Author: Feast Contributors
License: Apache 2.0
"""

import warnings
from datetime import timedelta

from feast import Entity, FeatureStore, FeatureView, Field
from feast.data_source import PushSource
from feast.types import Float64, Int64, String
from feast.value_type import ValueType


def main():
"""Demonstrate multiple join keys functionality."""
print("🎉 Multiple Join Keys Example for Feast")
print("=" * 50)

# ============================================================================
# 1. Create entities with multiple join keys
# ============================================================================
print("\n1. Creating entities with multiple join keys...")

# User entity with multiple identifiers
user_entity = Entity(
name="user",
join_keys=["user_id", "email", "username"], # Multiple join keys!
value_type=ValueType.STRING,
description="User entity with multiple unique identifiers"
)

# Product entity with multiple SKUs
product_entity = Entity(
name="product",
join_keys=["product_id", "sku", "barcode"],
value_type=ValueType.STRING,
description="Product entity with multiple product identifiers"
)

# Location entity with geographic identifiers
location_entity = Entity(
name="location",
join_keys=["location_id", "zip_code", "lat_lon"],
value_type=ValueType.STRING,
description="Location entity with multiple geographic identifiers"
)

print(f"✅ User entity join keys: {user_entity.join_keys}")
print(f"✅ Product entity join keys: {product_entity.join_keys}")
print(f"✅ Location entity join keys: {location_entity.join_keys}")

# ============================================================================
# 2. Demonstrate backward compatibility
# ============================================================================
print("\n2. Backward compatibility demonstration...")

# Legacy single join key still works
driver_entity = Entity(
name="driver",
join_keys=["driver_id"], # Single join key
value_type=ValueType.INT64
)

print(f"✅ Legacy entity join keys: {driver_entity.join_keys}")

# Show deprecation warning for .join_key property
print("\n⚠️ Demonstrating deprecation warning:")
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
legacy_key = driver_entity.join_key # This triggers deprecation warning

if w:
print(f" Warning: {w[0].message}")
print(f" Legacy key value: {legacy_key}")

# ============================================================================
# 3. Create feature views using multiple join keys
# ============================================================================
print("\n3. Creating feature views with multiple join keys...")

# User stats feature view
user_stats_source = PushSource(
name="user_stats_source",
batch_source=None, # Simplified for example
)

user_stats_fv = FeatureView(
name="user_stats",
entities=[user_entity], # Uses all join keys: user_id, email, username
ttl=timedelta(days=1),
schema=[
Field(name="total_orders", dtype=Int64),
Field(name="avg_order_value", dtype=Float64),
Field(name="loyalty_score", dtype=Float64),
],
source=user_stats_source,
)

print(f"✅ User stats feature view created with entity: {user_stats_fv.entities}")

# ============================================================================
# 4. Show practical usage patterns
# ============================================================================
print("\n4. Practical usage patterns...")

# Pattern 1: Access all join keys
def print_entity_info(entity):
print(f" Entity: {entity.name}")
print(f" All join keys: {entity.join_keys}")
print(f" Primary join key: {entity.join_keys[0]}")
print(f" Join key count: {len(entity.join_keys)}")

print("\n📊 Entity Information:")
for entity in [user_entity, product_entity, location_entity]:
print_entity_info(entity)
print()

# Pattern 2: Join key validation
def validate_entity_keys(entity, required_keys):
"""Validate that entity has all required join keys."""
missing_keys = set(required_keys) - set(entity.join_keys)
if missing_keys:
print(f"❌ Entity {entity.name} missing keys: {missing_keys}")
return False
print(f"✅ Entity {entity.name} has all required keys")
return True

print("\n🔍 Join Key Validation:")
validate_entity_keys(user_entity, ["user_id", "email"])
validate_entity_keys(product_entity, ["product_id", "sku", "barcode", "missing_key"])

# ============================================================================
# 5. Migration from legacy code
# ============================================================================
print("\n5. Migration patterns...")

print("\n📝 Before (deprecated):")
print(" entity_key = my_entity.join_key # ⚠️ Shows deprecation warning")

print("\n📝 After (recommended):")
print(" # For single key compatibility:")
print(" entity_key = my_entity.join_keys[0]")
print(" ")
print(" # For multiple keys support:")
print(" entity_keys = my_entity.join_keys")

# Example migration
print("\n🔄 Migration example:")
legacy_entity = Entity(name="customer", join_keys=["customer_id"])

# Old way (deprecated, shows warning)
print(" Old way (with warning):")
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
old_way = legacy_entity.join_key
print(f" key = {old_way}")

# New way (recommended)
print(" New way (no warning):")
new_way = legacy_entity.join_keys[0]
print(f" key = {new_way}")

# Best way (for new code)
print(" Best way (multiple keys support):")
best_way = legacy_entity.join_keys
print(f" keys = {best_way}")

print("\n🎉 Multiple join keys example completed successfully!")
print("\nKey takeaways:")
print("• Multiple join keys are now fully supported")
print("• Backward compatibility is maintained")
print("• Use join_keys instead of join_key for new code")
print("• Existing code works with deprecation warnings")
print("• Migration is straightforward and non-breaking")


if __name__ == "__main__":
main()
27 changes: 23 additions & 4 deletions go/internal/feast/model/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,32 @@ import (
)

type Entity struct {
Name string
JoinKey string
Name string
JoinKeys []string
}

// JoinKey returns the first join key for backward compatibility
func (e *Entity) JoinKey() string {
if len(e.JoinKeys) > 0 {
return e.JoinKeys[0]
}
return e.Name
}

func NewEntityFromProto(proto *core.Entity) *Entity {
var joinKeys []string

// Handle backward compatibility: prioritize join_keys, fall back to join_key
if len(proto.Spec.JoinKeys) > 0 {
joinKeys = append(joinKeys, proto.Spec.JoinKeys...)
} else if proto.Spec.JoinKey != "" {
joinKeys = []string{proto.Spec.JoinKey}
} else {
joinKeys = []string{proto.Spec.Name}
}

return &Entity{
Name: proto.Spec.Name,
JoinKey: proto.Spec.JoinKey,
Name: proto.Spec.Name,
JoinKeys: joinKeys,
}
}
2 changes: 1 addition & 1 deletion go/internal/feast/onlineserving/serving.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func GetEntityMaps(requestedFeatureViews []*FeatureViewAndRefs, entities []*mode
}

for _, entityName := range featureView.EntityNames {
joinKey := entitiesByName[entityName].JoinKey
joinKey := entitiesByName[entityName].JoinKey()
entityNameToJoinKeyMap[entityName] = joinKey

if alias, ok := joinKeyToAliasMap[joinKey]; ok {
Expand Down
6 changes: 6 additions & 0 deletions protos/feast/core/Entity.proto
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,19 @@ message EntitySpecV2 {
string description = 3;

// Join key for the entity (i.e. name of the column the entity maps to).
// Deprecated: Use join_keys instead for multiple join key support.
string join_key = 4;

// User defined metadata
map<string,string> tags = 8;

// Owner of the entity.
string owner = 10;

// Join keys for the entity (i.e. names of the columns the entity maps to).
// This supports multiple join keys. For backward compatibility, if this field
// is empty, the single join_key field will be used.
repeated string join_keys = 11;
}

message EntityMeta {
Expand Down
72 changes: 49 additions & 23 deletions sdk/python/feast/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ class Entity:
Attributes:
name: The unique name of the entity.
value_type: The type of the entity, such as string or float.
join_key: A property that uniquely identifies different entities within the
collection. The join_key property is typically used for joining entities
with their associated features. If not specified, defaults to the name.
join_keys: A list of properties that uniquely identify different entities within the
collection. The join_keys are typically used for joining entities
with their associated features. If not specified, defaults to [name].
description: A human-readable description.
tags: A dictionary of key-value pairs to store arbitrary metadata.
owner: The owner of the entity, typically the email of the primary maintainer.
Expand All @@ -45,7 +45,7 @@ class Entity:

name: str
value_type: ValueType
join_key: str
join_keys: List[str]
description: str
tags: Dict[str, str]
owner: str
Expand All @@ -68,10 +68,10 @@ def __init__(
Args:
name: The unique name of the entity.
join_keys (optional): A list of properties that uniquely identifies different entities
within the collection. This currently only supports a list of size one, but is
intended to eventually support multiple join keys.
value_type (optional): The type of the entity, such as string or float. If not specified,
it will be inferred from the schema of the underlying data source.
within the collection. Now supports multiple join keys for complex entity
relationships. If not specified, defaults to [name].
value_type (optional): The type of the entity, such as string or float. If not
specified, it will be inferred from the schema of the underlying data source.
description (optional): A human-readable description.
tags (optional): A dictionary of key-value pairs to store arbitrary metadata.
owner (optional): The owner of the entity, typically the email of the primary maintainer.
Expand All @@ -89,30 +89,40 @@ def __init__(
)
self.value_type = value_type or ValueType.UNKNOWN

if join_keys and len(join_keys) > 1:
# TODO(felixwang9817): When multiple join keys are supported, add a `join_keys` attribute
# and deprecate the `join_key` attribute.
raise ValueError(
"An entity may only have a single join key. "
"Multiple join keys will be supported in the future."
)
elif join_keys and len(join_keys) == 1:
self.join_key = join_keys[0]
# Handle join keys - support multiple join keys now
if join_keys:
self.join_keys = join_keys.copy()
else:
self.join_key = self.name
self.join_keys = [self.name]

self.description = description
self.tags = tags if tags is not None else {}
self.owner = owner
self.created_timestamp = None
self.last_updated_timestamp = None

@property
def join_key(self) -> str:
"""
Returns the first join key for backward compatibility.

Deprecated: Use join_keys instead for multiple join key support.
This property will be removed in Feast 0.57.0.
"""
warnings.warn(
"The 'join_key' property is deprecated and will be removed in Feast 0.57.0. "
"Use 'join_keys' instead for multiple join key support.",
DeprecationWarning,
stacklevel=2,
)
return self.join_keys[0] if self.join_keys else self.name

def __repr__(self):
return (
f"Entity(\n"
f" name={self.name!r},\n"
f" value_type={self.value_type!r},\n"
f" join_key={self.join_key!r},\n"
f" join_keys={self.join_keys!r},\n"
f" description={self.description!r},\n"
f" tags={self.tags!r},\n"
f" owner={self.owner!r},\n"
Expand All @@ -122,7 +132,7 @@ def __repr__(self):
)

def __hash__(self) -> int:
return hash((self.name, self.join_key))
return hash((self.name, tuple(self.join_keys)))

def __eq__(self, other):
if not isinstance(other, Entity):
Expand All @@ -131,7 +141,7 @@ def __eq__(self, other):
if (
self.name != other.name
or self.value_type != other.value_type
or self.join_key != other.join_key
or self.join_keys != other.join_keys
or self.description != other.description
or self.tags != other.tags
or self.owner != other.owner
Expand Down Expand Up @@ -170,9 +180,24 @@ def from_proto(cls, entity_proto: EntityProto):
Returns:
An Entity object based on the entity protobuf.
"""
# Handle backward compatibility: prioritize join_keys, fall back to join_key
if entity_proto.spec.join_keys:
join_keys_list = list(entity_proto.spec.join_keys)
elif entity_proto.spec.join_key:
warnings.warn(
f"Entity '{entity_proto.spec.name}' uses deprecated single join_key field "
"in protobuf. Future versions should use the join_keys field instead. "
"This compatibility will be removed in Feast 0.57.0.",
DeprecationWarning,
stacklevel=2,
)
join_keys_list = [entity_proto.spec.join_key]
else:
join_keys_list = None

entity = cls(
name=entity_proto.spec.name,
join_keys=[entity_proto.spec.join_key],
join_keys=join_keys_list,
value_type=ValueType(entity_proto.spec.value_type),
description=entity_proto.spec.description,
tags=dict(entity_proto.spec.tags),
Expand Down Expand Up @@ -204,7 +229,8 @@ def to_proto(self) -> EntityProto:
spec = EntitySpecProto(
name=self.name,
value_type=self.value_type.value,
join_key=self.join_key,
join_key=self.join_key, # For backward compatibility - first join key
join_keys=self.join_keys, # New field supporting multiple join keys
description=self.description,
tags=self.tags,
owner=self.owner,
Expand Down
Loading
Loading