From 9d82713acbfc8162046bbc596091739c448446c7 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 26 Jun 2026 11:02:48 -0700 Subject: [PATCH] Preserve sub-hour precision for LookML 'time' timeframe Looker's 'time' timeframe keeps full timestamp precision (to the second), but the adapter mapped it to hour granularity, silently collapsing all sub-hour rows. Map 'time' to second granularity, and add second/minute to the export granularity->timeframe map so they round-trip instead of degrading to 'date'. Update the four tests that asserted the old hour grain. --- sidemantic/adapters/lookml.py | 10 ++-- .../lookml/test_advanced_measure_types.py | 2 +- tests/adapters/lookml/test_edge_cases.py | 51 +++++++++++++++++++ tests/adapters/lookml/test_fixtures.py | 4 +- tests/adapters/lookml/test_parsing.py | 3 +- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/sidemantic/adapters/lookml.py b/sidemantic/adapters/lookml.py index becf8197..60bf8c53 100644 --- a/sidemantic/adapters/lookml.py +++ b/sidemantic/adapters/lookml.py @@ -736,7 +736,9 @@ def _parse_dimension_group( # Timeframes that truncate a timestamp to a coarser time grain. These keep # type="time" with a Sidemantic granularity so they behave as time dimensions. _TIME_GRANULARITY_TIMEFRAMES = { - "time": "hour", + # Looker's "time" timeframe keeps full timestamp precision (to the second); + # truncating to the hour silently collapses sub-hour rows. + "time": "second", "time_of_day": "hour", "hour": "hour", "minute": "minute", @@ -1852,7 +1854,7 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict: for dim in time_dims: # Extract base name (remove _date, _week, etc suffix) base_name = dim.name - for suffix in ["_date", "_week", "_month", "_quarter", "_year", "_time", "_hour"]: + for suffix in ["_date", "_week", "_month", "_quarter", "_year", "_time", "_hour", "_minute", "_second"]: if dim.name.endswith(suffix): base_name = dim.name[: -len(suffix)] break @@ -1862,7 +1864,9 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict: for base_name, dims in base_name_groups.items(): # Map granularity to timeframe granularity_mapping = { - "hour": "time", + "second": "time", + "minute": "minute", + "hour": "hour", "day": "date", "week": "week", "month": "month", diff --git a/tests/adapters/lookml/test_advanced_measure_types.py b/tests/adapters/lookml/test_advanced_measure_types.py index ef04d832..d61070fd 100644 --- a/tests/adapters/lookml/test_advanced_measure_types.py +++ b/tests/adapters/lookml/test_advanced_measure_types.py @@ -129,7 +129,7 @@ class TestDimensionGroupTimeframes: def test_time_truncation_timeframes_are_time(self, graph): model = graph.get_model("events_calendar") for tf, gran in [ - ("occurred_time", "hour"), + ("occurred_time", "second"), ("occurred_date", "day"), ("occurred_week", "week"), ("occurred_month", "month"), diff --git a/tests/adapters/lookml/test_edge_cases.py b/tests/adapters/lookml/test_edge_cases.py index 0e32040d..6121ec18 100644 --- a/tests/adapters/lookml/test_edge_cases.py +++ b/tests/adapters/lookml/test_edge_cases.py @@ -2212,6 +2212,57 @@ def test_lookml_view_with_unsupported_derived_table_not_defaulted(): assert model.table is None # not fabricated as a physical table named after the view +def test_lookml_time_timeframe_keeps_second_precision(): + """Looker's "time" timeframe keeps to-the-second precision, not hour.""" + graph = _parse_lkml( + """ +view: v { + sql_table_name: t ;; + dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; } + dimension_group: created { + type: time + timeframes: [time, date, minute] + sql: ${TABLE}.created_at ;; + } +} +""" + ) + model = graph.get_model("v") + assert model.get_dimension("created_time").granularity == "second" + assert model.get_dimension("created_minute").granularity == "minute" + assert model.get_dimension("created_date").granularity == "day" + + +def test_lookml_time_grain_roundtrip(): + """time/hour/minute/date grains round-trip through export without grain or suffix corruption.""" + import tempfile + + graph = _parse_lkml( + """ +view: v { + sql_table_name: t ;; + dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; } + dimension_group: created { + type: time + timeframes: [time, hour, minute, date] + sql: ${TABLE}.created_at ;; + } +} +""" + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + grains = { + d.name: d.granularity for d in LookMLAdapter().parse(Path(out)).get_model("v").dimensions if d.type == "time" + } + assert grains == { + "created_time": "second", + "created_hour": "hour", + "created_minute": "minute", + "created_date": "day", + } + + def test_lookml_view_without_table_defaults_to_view_name(): """A view with no sql_table_name/derived_table should default its table to the view name.""" from sidemantic import SemanticLayer diff --git a/tests/adapters/lookml/test_fixtures.py b/tests/adapters/lookml/test_fixtures.py index 766da461..01caf966 100644 --- a/tests/adapters/lookml/test_fixtures.py +++ b/tests/adapters/lookml/test_fixtures.py @@ -69,7 +69,7 @@ def test_redshift_etl_errors_time_dimensions(self): model = self.graph.get_model("redshift_etl_errors") assert model.get_dimension("error_time") is not None assert model.get_dimension("error_time").type == "time" - assert model.get_dimension("error_time").granularity == "hour" + assert model.get_dimension("error_time").granularity == "second" assert model.get_dimension("error_date") is not None assert model.get_dimension("error_date").type == "time" assert model.get_dimension("error_date").granularity == "day" @@ -332,7 +332,7 @@ def test_created_time_group(self): assert model.get_dimension("created_month") is not None assert model.get_dimension("created_year") is not None assert model.get_dimension("created_time") is not None - assert model.get_dimension("created_time").granularity == "hour" + assert model.get_dimension("created_time").granularity == "second" def test_shipped_time_group(self): model = self.graph.get_model("order_items") diff --git a/tests/adapters/lookml/test_parsing.py b/tests/adapters/lookml/test_parsing.py index e8beb23b..30c3b62e 100644 --- a/tests/adapters/lookml/test_parsing.py +++ b/tests/adapters/lookml/test_parsing.py @@ -119,7 +119,8 @@ def test_lookml_adapter_ecommerce(): # Test dimension group with multiple timeframes created_time = orders.get_dimension("created_time") assert created_time is not None - assert created_time.granularity == "hour" + # Looker's "time" timeframe keeps to-the-second precision. + assert created_time.granularity == "second" created_date = orders.get_dimension("created_date") assert created_date is not None