diff --git a/.secrets.baseline b/.secrets.baseline index bdeaa2d9d75..8d367153a4e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1401,7 +1401,7 @@ "filename": "sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 21 + "line_number": 83 } ], "sdk/python/tests/unit/infra/offline_stores/test_offline_store.py": [ @@ -1564,5 +1564,5 @@ } ] }, - "generated_at": "2026-08-20T15:20:58Z" + "generated_at": "2026-08-24T05:29:25Z" } diff --git a/sdk/python/feast/infra/offline_stores/contrib/clickhouse_offline_store/clickhouse.py b/sdk/python/feast/infra/offline_stores/contrib/clickhouse_offline_store/clickhouse.py index 9e353151746..c84fa3678a4 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/clickhouse_offline_store/clickhouse.py +++ b/sdk/python/feast/infra/offline_stores/contrib/clickhouse_offline_store/clickhouse.py @@ -533,10 +533,13 @@ def _append_alias(field_names: List[str], alias: str) -> List[str]: entity_dataframe."{{featureview.name}}__entity_row_unique_id" FROM "{{ featureview.name }}__subquery" AS subquery INNER JOIN "{{ featureview.name }}__entity_dataframe" AS entity_dataframe - ON TRUE + {% if featureview.entities %} {% for entity in featureview.entities %} - AND subquery."{{ entity }}" = entity_dataframe."{{ entity }}" + {% if loop.first %}ON{% else %}AND{% endif %} subquery."{{ entity }}" = entity_dataframe."{{ entity }}" {% endfor %} + {% else %} + ON 1 = 1 + {% endif %} WHERE TRUE AND subquery.event_timestamp <= entity_dataframe.entity_timestamp @@ -625,6 +628,6 @@ def _append_alias(field_names: List[str], alias: str) -> List[str]: ,"{% if full_feature_names %}{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}{% else %}{{ featureview.field_mapping.get(feature, feature) }}{% endif %}" {% endfor %} FROM "{{ featureview.name }}__cleaned" -) AS "{{featureview.name}}" USING ("{{featureview.name}}__entity_row_unique_id") +) AS "{{featureview.name}}" ON "{{featureview.name}}"."{{featureview.name}}__entity_row_unique_id" = entity_dataframe."{{featureview.name}}__entity_row_unique_id" {% endfor %} """ diff --git a/sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py b/sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py index 7789cde72b3..e37fba1c845 100644 --- a/sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py +++ b/sdk/python/tests/unit/infra/offline_stores/test_clickhouse.py @@ -5,12 +5,74 @@ import pytest +from feast.infra.offline_stores.contrib.clickhouse_offline_store.clickhouse import ( + MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN, + build_point_in_time_query, +) from feast.infra.utils.clickhouse.clickhouse_config import ClickhouseConfig from feast.infra.utils.clickhouse.connection_utils import get_client, thread_local logger = logging.getLogger(__name__) +def _feature_view_query_context(name, entities): + return { + "name": name, + "ttl": 3600, + "entities": entities, + "features": ["feature1"], + "field_mapping": {}, + "timestamp_field": "event_timestamp", + "created_timestamp_column": None, + "table_subquery": f"{name}_table", + "entity_selections": [f'"{entity}" as "{entity}"' for entity in entities], + "min_event_timestamp": None, + "max_event_timestamp": "2023-01-01", + "date_partition_column": None, + "timestamp_field_type": None, + } + + +class TestMultipleFeatureViewPointInTimeJoinQuery: + """ + ClickHouse rejects `ON TRUE` (INVALID_JOIN_ON_EXPRESSION) and rejects more than + one `USING` clause per query (Code: 48). Both appeared in the multi-feature-view + point-in-time join template, breaking any query that joined 2+ FeatureViews, as + well as every single-FeatureView query via the `ON TRUE` clause. + """ + + def test_no_on_true_or_multiple_using_with_entities(self): + query = build_point_in_time_query( + [ + _feature_view_query_context("fv1", ["driver_id"]), + _feature_view_query_context("fv2", ["driver_id"]), + ], + left_table_query_string="entity_table", + entity_df_event_timestamp_col="event_timestamp", + entity_df_columns=["driver_id", "event_timestamp"], + query_template=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN, + ) + + assert "ON TRUE" not in query + assert 'ON "fv1"."fv1__entity_row_unique_id"' in query + assert 'ON "fv2"."fv2__entity_row_unique_id"' in query + assert 'USING ("fv1__entity_row_unique_id")' not in query + assert 'USING ("fv2__entity_row_unique_id")' not in query + + def test_no_on_true_with_no_entities(self): + """Non-entity FeatureViews (entities=[]) must still produce a valid ON clause.""" + query = build_point_in_time_query( + [_feature_view_query_context("fv1", [])], + left_table_query_string="entity_table", + entity_df_event_timestamp_col="event_timestamp", + entity_df_columns=["event_timestamp"], + query_template=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN, + ) + + assert "ON TRUE" not in query + assert "ON 1 = 1" in query + + @pytest.fixture def clickhouse_config(): """Create a test ClickHouse configuration."""