@@ -2290,6 +2290,137 @@ def test_lookml_view_with_unsupported_derived_table_not_defaulted():
22902290 assert model .table is None # not fabricated as a physical table named after the view
22912291
22922292
2293+ def test_lookml_time_timeframe_keeps_second_precision ():
2294+ """Looker's "time" timeframe keeps to-the-second precision, not hour."""
2295+ graph = _parse_lkml (
2296+ """
2297+ view: v {
2298+ sql_table_name: t ;;
2299+ dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; }
2300+ dimension_group: created {
2301+ type: time
2302+ timeframes: [time, date, minute]
2303+ sql: ${TABLE}.created_at ;;
2304+ }
2305+ }
2306+ """
2307+ )
2308+ model = graph .get_model ("v" )
2309+ assert model .get_dimension ("created_time" ).granularity == "second"
2310+ assert model .get_dimension ("created_minute" ).granularity == "minute"
2311+ assert model .get_dimension ("created_date" ).granularity == "day"
2312+
2313+
2314+ def test_lookml_time_grain_roundtrip ():
2315+ """time/hour/minute/date grains round-trip through export without grain or suffix corruption."""
2316+ import tempfile
2317+
2318+ graph = _parse_lkml (
2319+ """
2320+ view: v {
2321+ sql_table_name: t ;;
2322+ dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; }
2323+ dimension_group: created {
2324+ type: time
2325+ timeframes: [time, hour, minute, date]
2326+ sql: ${TABLE}.created_at ;;
2327+ }
2328+ }
2329+ """
2330+ )
2331+ out = tempfile .mktemp (suffix = ".lkml" )
2332+ LookMLAdapter ().export (graph , out )
2333+ grains = {
2334+ d .name : d .granularity for d in LookMLAdapter ().parse (Path (out )).get_model ("v" ).dimensions if d .type == "time"
2335+ }
2336+ assert grains == {
2337+ "created_time" : "second" ,
2338+ "created_hour" : "hour" ,
2339+ "created_minute" : "minute" ,
2340+ "created_date" : "day" ,
2341+ }
2342+
2343+
2344+ def test_lookml_native_second_grain_roundtrips ():
2345+ """A second-grain dimension not imported from LookML (no meta) exports as `second`, not `time`."""
2346+ import tempfile
2347+
2348+ from sidemantic import Dimension , Model
2349+ from sidemantic .core .semantic_graph import SemanticGraph
2350+
2351+ graph = SemanticGraph ()
2352+ graph .add_model (
2353+ Model (
2354+ name = "v" ,
2355+ table = "t" ,
2356+ primary_key = "id" ,
2357+ dimensions = [
2358+ Dimension (name = "id" , type = "numeric" , sql = "id" ),
2359+ Dimension (name = "created_second" , type = "time" , granularity = "second" , sql = "{model}.created_at" ),
2360+ ],
2361+ )
2362+ )
2363+ out = tempfile .mktemp (suffix = ".lkml" )
2364+ LookMLAdapter ().export (graph , out )
2365+ names = {d .name for d in LookMLAdapter ().parse (Path (out )).get_model ("v" ).dimensions if d .type == "time" }
2366+ assert "created_second" in names
2367+
2368+
2369+ def test_lookml_native_time_named_second_grain_roundtrips ():
2370+ """A native second-grain dim named *_time must export as `time` so the name round-trips."""
2371+ import tempfile
2372+
2373+ from sidemantic import Dimension , Model
2374+ from sidemantic .core .semantic_graph import SemanticGraph
2375+
2376+ graph = SemanticGraph ()
2377+ graph .add_model (
2378+ Model (
2379+ name = "v" ,
2380+ table = "t" ,
2381+ primary_key = "id" ,
2382+ dimensions = [
2383+ Dimension (name = "id" , type = "numeric" , sql = "id" ),
2384+ Dimension (name = "created_time" , type = "time" , granularity = "second" , sql = "{model}.created_at" ),
2385+ ],
2386+ )
2387+ )
2388+ out = tempfile .mktemp (suffix = ".lkml" )
2389+ LookMLAdapter ().export (graph , out )
2390+ names = {d .name for d in LookMLAdapter ().parse (Path (out )).get_model ("v" ).dimensions if d .type == "time" }
2391+ assert "created_time" in names # not renamed to created_second
2392+
2393+
2394+ def test_lookml_time_and_second_timeframes_no_duplicate_export ():
2395+ """A group with both `time` and `second` (both -> second grain) must not emit duplicate timeframes."""
2396+ import re
2397+ import tempfile
2398+
2399+ graph = _parse_lkml (
2400+ """
2401+ view: v {
2402+ sql_table_name: t ;;
2403+ dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; }
2404+ dimension_group: created {
2405+ type: time
2406+ timeframes: [time, second, hour]
2407+ sql: ${TABLE}.created_at ;;
2408+ }
2409+ }
2410+ """
2411+ )
2412+ out = tempfile .mktemp (suffix = ".lkml" )
2413+ LookMLAdapter ().export (graph , out )
2414+ text = open (out ).read ()
2415+ timeframes = re .search (r"timeframes:\s*\[([^\]]*)\]" , text ).group (1 )
2416+ parts = [p .strip () for p in timeframes .split ("," )]
2417+ assert len (parts ) == len (set (parts )), f"duplicate timeframes exported: { parts } "
2418+ # Both `time` and `second` are preserved (no collapse/drop) via the stored
2419+ # original timeframe, so re-import keeps every member.
2420+ names = {d .name for d in LookMLAdapter ().parse (Path (out )).get_model ("v" ).dimensions if d .type == "time" }
2421+ assert {"created_time" , "created_second" , "created_hour" } <= names
2422+
2423+
22932424def test_lookml_view_without_table_defaults_to_view_name ():
22942425 """A view with no sql_table_name/derived_table should default its table to the view name."""
22952426 from sidemantic import SemanticLayer
0 commit comments