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
10 changes: 7 additions & 3 deletions sidemantic/adapters/lookml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve LookML second timeframes on export

When a LookML dimension_group contains a second timeframe, it imports as a *_second dimension with granularity == "second", but this export mapping writes every second-grain dimension back as the LookML time timeframe. Fresh evidence in this revision is that *_second is now grouped with the same base name as *_time, so a group with both time and second exports duplicate timeframes: [time, time]; re-importing drops created_second and creates duplicate created_time fields, corrupting round-trip models that use Looker's second timeframe.

Useful? React with 👍 / 👎.

"minute": "minute",
"hour": "hour",
"day": "date",
"week": "week",
"month": "month",
Expand Down
2 changes: 1 addition & 1 deletion tests/adapters/lookml/test_advanced_measure_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
51 changes: 51 additions & 0 deletions tests/adapters/lookml/test_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/adapters/lookml/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion tests/adapters/lookml/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading