@@ -316,3 +316,234 @@ def test_retention_week_granularity():
316316 assert week_data [0 ][4 ] == 100.0
317317 assert week_data [1 ][4 ] == 100.0
318318 assert week_data [2 ][4 ] == 50.0
319+
320+
321+ def test_retention_model_placeholder_expansion_sql_model ():
322+ """Test that {model} placeholders in cohort_event/activity_event are expanded to table alias."""
323+ events = Model (
324+ name = "events" ,
325+ sql = """
326+ SELECT 1 AS uid, 'signup' AS event, '2024-01-01'::DATE AS ts
327+ UNION ALL SELECT 1, 'login', '2024-01-02'::DATE
328+ """ ,
329+ primary_key = "uid" ,
330+ dimensions = [
331+ Dimension (name = "uid" , sql = "uid" , type = "categorical" ),
332+ Dimension (name = "event" , sql = "event" , type = "categorical" ),
333+ Dimension (name = "ts" , sql = "ts" , type = "time" ),
334+ ],
335+ metrics = [],
336+ )
337+
338+ retention = Metric (
339+ name = "retention" ,
340+ type = "retention" ,
341+ entity = "uid" ,
342+ cohort_event = "{model}.event = 'signup'" ,
343+ activity_event = "{model}.event IS NOT NULL" ,
344+ periods = 1 ,
345+ retention_granularity = "day" ,
346+ )
347+
348+ graph = SemanticGraph ()
349+ graph .add_model (events )
350+ graph .add_metric (retention )
351+
352+ generator = SQLGenerator (graph )
353+ sql = generator .generate (metrics = ["retention" ], dimensions = [])
354+
355+ # {model} should be replaced with 't' (SQL subquery alias)
356+ assert "{model}" not in sql
357+ assert "t.event = 'signup'" in sql
358+ assert "t.event IS NOT NULL" in sql
359+
360+ # Should still execute correctly
361+ conn = duckdb .connect (":memory:" )
362+ result = conn .execute (sql )
363+ rows = df_rows (result )
364+ assert len (rows ) > 0
365+
366+
367+ def test_retention_model_placeholder_expansion_table_model ():
368+ """Test that {model} placeholders are stripped for table-backed models."""
369+ conn = duckdb .connect (":memory:" )
370+ conn .execute ("""
371+ CREATE TABLE test_events AS
372+ SELECT 1 AS uid, 'signup' AS event, '2024-01-01'::DATE AS ts
373+ UNION ALL SELECT 1, 'login', '2024-01-02'::DATE
374+ """ )
375+
376+ events = Model (
377+ name = "events" ,
378+ table = "test_events" ,
379+ primary_key = "uid" ,
380+ dimensions = [
381+ Dimension (name = "uid" , sql = "uid" , type = "categorical" ),
382+ Dimension (name = "event" , sql = "event" , type = "categorical" ),
383+ Dimension (name = "ts" , sql = "ts" , type = "time" ),
384+ ],
385+ metrics = [],
386+ )
387+
388+ retention = Metric (
389+ name = "retention" ,
390+ type = "retention" ,
391+ entity = "uid" ,
392+ cohort_event = "{model}.event = 'signup'" ,
393+ activity_event = "{model}.event IS NOT NULL" ,
394+ periods = 1 ,
395+ retention_granularity = "day" ,
396+ )
397+
398+ graph = SemanticGraph ()
399+ graph .add_model (events )
400+ graph .add_metric (retention )
401+
402+ generator = SQLGenerator (graph )
403+ sql = generator .generate (metrics = ["retention" ], dimensions = [])
404+
405+ # {model}. should be stripped for table-backed models
406+ assert "{model}" not in sql
407+ assert "event = 'signup'" in sql
408+
409+ result = conn .execute (sql )
410+ rows = df_rows (result )
411+ assert len (rows ) > 0
412+
413+
414+ def test_retention_periods_zero_raises_validation_error ():
415+ """Test that periods=0 raises a validation error instead of silently becoming 28."""
416+ events = Model (
417+ name = "events" ,
418+ sql = """
419+ SELECT 1 AS uid, 'signup' AS event, '2024-01-01'::DATE AS ts
420+ """ ,
421+ primary_key = "uid" ,
422+ dimensions = [
423+ Dimension (name = "uid" , sql = "uid" , type = "categorical" ),
424+ Dimension (name = "event" , sql = "event" , type = "categorical" ),
425+ Dimension (name = "ts" , sql = "ts" , type = "time" ),
426+ ],
427+ metrics = [],
428+ )
429+
430+ retention = Metric (
431+ name = "retention" ,
432+ type = "retention" ,
433+ entity = "uid" ,
434+ cohort_event = "event = 'signup'" ,
435+ periods = 0 ,
436+ )
437+
438+ graph = SemanticGraph ()
439+ graph .add_model (events )
440+ graph .add_metric (retention )
441+
442+ generator = SQLGenerator (graph )
443+ with pytest .raises (ValueError , match = "Invalid periods value" ):
444+ generator .generate (metrics = ["retention" ], dimensions = [])
445+
446+
447+ def test_retention_yaml_retention_granularity_key ():
448+ """Test that YAML with retention_granularity: week parses correctly."""
449+ import os
450+ import tempfile
451+
452+ from sidemantic .adapters .sidemantic import SidemanticAdapter
453+
454+ yaml_content = """
455+ models:
456+ - name: events
457+ table: events
458+ dimensions:
459+ - name: user_id
460+ type: categorical
461+ - name: ts
462+ type: time
463+ metrics:
464+ - name: weekly_retention
465+ type: retention
466+ entity: user_id
467+ cohort_event: "event = 'signup'"
468+ retention_granularity: week
469+ periods: 4
470+ """
471+ with tempfile .NamedTemporaryFile (mode = "w" , suffix = ".yml" , delete = False ) as f :
472+ f .write (yaml_content )
473+ tmp_path = f .name
474+
475+ try :
476+ adapter = SidemanticAdapter ()
477+ graph = adapter .parse (tmp_path )
478+ model = graph .get_model ("events" )
479+ metric = model .get_metric ("weekly_retention" )
480+ assert metric .retention_granularity == "week"
481+ assert metric .periods == 4
482+ finally :
483+ os .unlink (tmp_path )
484+
485+
486+ def test_retention_yaml_granularity_fallback ():
487+ """Test that YAML with granularity: month also parses for retention metrics."""
488+ import os
489+ import tempfile
490+
491+ from sidemantic .adapters .sidemantic import SidemanticAdapter
492+
493+ yaml_content = """
494+ metrics:
495+ - name: monthly_retention
496+ type: retention
497+ entity: user_id
498+ cohort_event: "event = 'signup'"
499+ granularity: month
500+ periods: 12
501+ """
502+ with tempfile .NamedTemporaryFile (mode = "w" , suffix = ".yml" , delete = False ) as f :
503+ f .write (yaml_content )
504+ tmp_path = f .name
505+
506+ try :
507+ adapter = SidemanticAdapter ()
508+ graph = adapter .parse (tmp_path )
509+ metric = graph .get_metric ("monthly_retention" )
510+ assert metric .retention_granularity == "month"
511+ assert metric .periods == 12
512+ finally :
513+ os .unlink (tmp_path )
514+
515+
516+ def test_retention_export_roundtrip_retention_granularity ():
517+ """Test that export uses retention_granularity key and roundtrips correctly."""
518+ import os
519+ import tempfile
520+
521+ from sidemantic .adapters .sidemantic import SidemanticAdapter
522+
523+ # Create a graph with a retention metric
524+ graph = SemanticGraph ()
525+ retention = Metric (
526+ name = "weekly_retention" ,
527+ type = "retention" ,
528+ entity = "user_id" ,
529+ cohort_event = "event = 'signup'" ,
530+ retention_granularity = "week" ,
531+ periods = 4 ,
532+ )
533+ graph .add_metric (retention )
534+
535+ # Export
536+ with tempfile .NamedTemporaryFile (mode = "w" , suffix = ".yml" , delete = False ) as f :
537+ tmp_path = f .name
538+
539+ try :
540+ adapter = SidemanticAdapter ()
541+ adapter .export (graph , tmp_path )
542+
543+ # Re-parse and verify
544+ graph2 = adapter .parse (tmp_path )
545+ metric = graph2 .get_metric ("weekly_retention" )
546+ assert metric .retention_granularity == "week"
547+ assert metric .periods == 4
548+ finally :
549+ os .unlink (tmp_path )
0 commit comments