diff --git a/README.md b/README.md index 3c490c24..d1353c76 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ SQL-first semantic layer for consistent metrics across your data stack. -Formats: Sidemantic, Cube, MetricFlow (dbt), LookML, Hex, Rill, Superset, Omni, BSL. -Databases: DuckDB, MotherDuck, PostgreSQL, BigQuery, Snowflake, ClickHouse, Databricks, Spark SQL. +- **Formats:** Sidemantic, Cube, MetricFlow (dbt), LookML, Hex, Rill, Superset, Omni, BSL +- **Databases:** DuckDB, MotherDuck, PostgreSQL, BigQuery, Snowflake, ClickHouse, Databricks, Spark SQL [Documentation](https://sidemantic.com) • [GitHub](https://github.com/sidequery/sidemantic) diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 00000000..b9df79a5 --- /dev/null +++ b/tests/adapters/__init__.py @@ -0,0 +1 @@ +"""Tests for semantic adapters.""" diff --git a/tests/adapters/cube/__init__.py b/tests/adapters/cube/__init__.py new file mode 100644 index 00000000..144cb680 --- /dev/null +++ b/tests/adapters/cube/__init__.py @@ -0,0 +1 @@ +"""Tests for Cube adapter.""" diff --git a/tests/adapters/cube/test_conversion.py b/tests/adapters/cube/test_conversion.py new file mode 100644 index 00000000..01eae01a --- /dev/null +++ b/tests/adapters/cube/test_conversion.py @@ -0,0 +1,218 @@ +"""Tests for Cube adapter - cross-format conversion.""" + +import tempfile +from pathlib import Path + +import yaml + +from sidemantic.adapters.cube import CubeAdapter +from sidemantic.adapters.hex import HexAdapter +from sidemantic.adapters.lookml import LookMLAdapter +from sidemantic.adapters.metricflow import MetricFlowAdapter +from sidemantic.adapters.omni import OmniAdapter +from sidemantic.adapters.rill import RillAdapter +from sidemantic.adapters.sidemantic import SidemanticAdapter +from sidemantic.adapters.superset import SupersetAdapter + + +def test_cube_to_metricflow_conversion(): + """Test converting Cube format to MetricFlow format.""" + cube_adapter = CubeAdapter() + graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") + + mf_adapter = MetricFlowAdapter() + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + mf_adapter.export(graph, temp_path) + graph2 = mf_adapter.parse(temp_path) + + assert "orders" in graph2.models + orders = graph2.models["orders"] + + dim_names = [d.name for d in orders.dimensions] + assert "status" in dim_names + + measure_names = [m.name for m in orders.metrics] + assert "revenue" in measure_names + + if orders.segments: + assert len(orders.segments) > 0 + + finally: + temp_path.unlink(missing_ok=True) + + +def test_cube_to_lookml_conversion(): + """Test converting Cube format to LookML format.""" + cube_adapter = CubeAdapter() + graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") + + lookml_adapter = LookMLAdapter() + with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: + temp_path = Path(f.name) + + try: + lookml_adapter.export(graph, temp_path) + graph2 = lookml_adapter.parse(temp_path) + + assert "orders" in graph2.models + orders = graph2.models["orders"] + + dim_names = [d.name for d in orders.dimensions] + assert "status" in dim_names + + measure_names = [m.name for m in orders.metrics] + assert "revenue" in measure_names + + if orders.segments: + assert len(orders.segments) > 0 + + finally: + temp_path.unlink(missing_ok=True) + + +def test_cube_to_hex_conversion(): + """Test converting Cube format to Hex format.""" + cube_adapter = CubeAdapter() + graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") + + hex_adapter = HexAdapter() + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + hex_adapter.export(graph, temp_path) + graph2 = hex_adapter.parse(temp_path) + + assert "orders" in graph2.models + orders = graph2.models["orders"] + + dim_names = [d.name for d in orders.dimensions] + assert "status" in dim_names + + measure_names = [m.name for m in orders.metrics] + assert "revenue" in measure_names + + finally: + temp_path.unlink(missing_ok=True) + + +def test_cube_to_rill_conversion(): + """Test converting Cube format to Rill format.""" + cube_adapter = CubeAdapter() + graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") + + rill_adapter = RillAdapter() + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + rill_adapter.export(graph, output_path) + + graph2 = rill_adapter.parse(output_path / "orders.yaml") + + assert "orders" in graph2.models + orders = graph2.models["orders"] + + dim_names = [d.name for d in orders.dimensions] + assert "status" in dim_names + + measure_names = [m.name for m in orders.metrics] + assert "revenue" in measure_names + + +def test_cube_to_superset_conversion(): + """Test converting Cube schema to Superset dataset.""" + cube_adapter = CubeAdapter() + superset_adapter = SupersetAdapter() + + graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + superset_adapter.export(graph, output_path) + + superset_graph = superset_adapter.parse(output_path / "orders.yaml") + + assert "orders" in superset_graph.models + + +def test_cube_to_omni_conversion(): + """Test converting Cube schema to Omni view.""" + cube_adapter = CubeAdapter() + omni_adapter = OmniAdapter() + + graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + omni_adapter.export(graph, output_path) + + omni_graph = omni_adapter.parse(output_path) + + assert "orders" in omni_graph.models + + +def test_sidemantic_to_cube_export(): + """Test export from Sidemantic to Cube format.""" + # Load native format + native_adapter = SidemanticAdapter() + graph = native_adapter.parse("tests/fixtures/sidemantic/orders.yml") + + # Export to Cube + cube_adapter = CubeAdapter() + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + cube_adapter.export(graph, temp_path) + + # Verify file structure + with open(temp_path) as f: + data = yaml.safe_load(f) + + assert "cubes" in data + assert len(data["cubes"]) == 2 + + # Verify orders cube + orders_cube = next(c for c in data["cubes"] if c["name"] == "orders") + assert orders_cube["sql_table"] == "public.orders" + assert "dimensions" in orders_cube + assert "measures" in orders_cube + # Note: joins only exported when foreign entity name matches target model name + + # Verify round-trip (parse exported file) + graph2 = cube_adapter.parse(temp_path) + assert len(graph2.models) == 2 + + finally: + temp_path.unlink(missing_ok=True) + + +def test_sidemantic_to_cube_roundtrip(): + """Test Sidemantic -> Cube -> Sidemantic round-trip.""" + # Load native + native_adapter = SidemanticAdapter() + graph = native_adapter.parse("tests/fixtures/sidemantic/orders.yml") + + # Export to Cube + cube_adapter = CubeAdapter() + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + cube_path = Path(f.name) + + try: + cube_adapter.export(graph, cube_path) + + # Import from Cube + graph2 = cube_adapter.parse(cube_path) + + # Verify structure preserved + assert set(graph2.models.keys()) == set(graph.models.keys()) + + # Verify measures preserved + orders1 = graph.models["orders"] + orders2 = graph2.models["orders"] + assert len(orders1.metrics) == len(orders2.metrics) + + finally: + cube_path.unlink(missing_ok=True) diff --git a/tests/adapters/test_cube.py b/tests/adapters/cube/test_parsing.py similarity index 96% rename from tests/adapters/test_cube.py rename to tests/adapters/cube/test_parsing.py index e0b33a0e..08698110 100644 --- a/tests/adapters/test_cube.py +++ b/tests/adapters/cube/test_parsing.py @@ -1,9 +1,7 @@ -"""Tests for Cube adapter.""" +"""Tests for Cube adapter - parsing.""" from pathlib import Path -import pytest - from sidemantic.adapters.cube import CubeAdapter @@ -44,6 +42,19 @@ def test_cube_adapter(): completed_segment = next((s for s in orders.segments if s.name == "completed"), None) assert completed_segment is not None + # Verify segment SQL was converted from ${CUBE} to {model} + assert "{model}" in completed_segment.sql + assert "${CUBE}" not in completed_segment.sql + + # Verify measure with filter was imported + completed_revenue = next(m for m in orders.metrics if m.name == "completed_revenue") + assert completed_revenue.filters is not None + assert len(completed_revenue.filters) > 0 + + # Verify ratio metric (calculated measure) was detected + conversion_rate = next(m for m in orders.metrics if m.name == "conversion_rate") + assert conversion_rate.type in ["ratio", "derived"] + def test_cube_adapter_join_discovery(): """Test that Cube adapter enables join discovery.""" @@ -53,8 +64,6 @@ def test_cube_adapter_join_discovery(): # Check that relationships were imported orders = graph.get_model("orders") assert len(orders.relationships) > 0 - # Note: The Cube example only has one model, so no actual join path can be tested - # but we verify that the relationship structure was imported correctly def test_cube_adapter_pre_aggregations(): @@ -65,9 +74,6 @@ def test_cube_adapter_pre_aggregations(): orders = graph.get_model("orders") assert orders is not None - # Check pre-aggregations were parsed - # Note: Pre-aggregations are not stored as first-class objects in SemanticGraph - # but the adapter should handle them gracefully during parsing assert len(orders.dimensions) > 0 assert len(orders.metrics) > 0 assert len(orders.segments) == 2 @@ -111,7 +117,6 @@ def test_cube_adapter_multi_cube(): count_metric = orders.get_metric("count") assert count_metric is not None if hasattr(count_metric, "drill_fields") and count_metric.drill_fields: - # Verify drill fields include cross-cube references assert any("customers" in str(field) for field in count_metric.drill_fields) @@ -124,7 +129,6 @@ def test_cube_adapter_segments(): orders = graph.get_model("orders") completed_segment = next((s for s in orders.segments if s.name == "completed"), None) assert completed_segment is not None - # Check that ${CUBE} was replaced with {model} assert "{model}" in completed_segment.sql or "orders" in completed_segment.sql.lower() # Test customers segments @@ -146,10 +150,6 @@ def test_cube_adapter_drill_members(): orders = graph.get_model("orders") count_metric = orders.get_metric("count") assert count_metric is not None - - # Note: drill_members parsing is not yet implemented in Cube adapter - # This test will validate when the feature is added - # For now, we just verify the metric exists and has correct basic properties assert count_metric.name == "count" assert count_metric.agg == "count" @@ -400,7 +400,7 @@ def test_cube_financial_analytics(): is_recurring_dim = transactions.get_dimension("is_recurring") assert is_recurring_dim is not None - assert is_recurring_dim.type == "categorical" # boolean maps to categorical + assert is_recurring_dim.type == "categorical" # Check transaction measures with filters credit_amount = transactions.get_metric("credit_amount") @@ -748,7 +748,3 @@ def test_cube_adapter_cube_name_reference(): assert high_value_segment is not None assert "{model}" in high_value_segment.sql assert "${custom_cube_ref}" not in high_value_segment.sql - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/adapters/cube/test_query.py b/tests/adapters/cube/test_query.py new file mode 100644 index 00000000..1405a013 --- /dev/null +++ b/tests/adapters/cube/test_query.py @@ -0,0 +1,40 @@ +"""Tests for Cube adapter - query compilation.""" + +from sidemantic import SemanticLayer +from sidemantic.adapters.cube import CubeAdapter + + +def test_query_imported_cube_example(): + """Test that we can compile queries from imported Cube schema.""" + adapter = CubeAdapter() + graph = adapter.parse("tests/fixtures/cube/orders.yml") + + layer = SemanticLayer() + layer.graph = graph + + # Test basic metric query + sql = layer.compile(metrics=["orders.revenue"]) + assert "SUM" in sql.upper() + + # Test with dimension + sql = layer.compile(metrics=["orders.revenue", "orders.count"], dimensions=["orders.status"]) + assert "GROUP BY" in sql.upper() + assert "status" in sql.lower() + + # Test with segment + sql = layer.compile(metrics=["orders.revenue"], segments=["orders.completed"]) + assert "WHERE" in sql.upper() + assert "status" in sql.lower() + + +def test_query_with_time_dimension_cube(): + """Test querying time dimensions from Cube import.""" + adapter = CubeAdapter() + graph = adapter.parse("tests/fixtures/cube/orders.yml") + + layer = SemanticLayer() + layer.graph = graph + + sql = layer.compile(metrics=["orders.revenue"], dimensions=["orders.created_at"]) + assert "created_at" in sql.lower() + assert "GROUP BY" in sql.upper() diff --git a/tests/adapters/cube/test_roundtrip.py b/tests/adapters/cube/test_roundtrip.py new file mode 100644 index 00000000..f3cb2742 --- /dev/null +++ b/tests/adapters/cube/test_roundtrip.py @@ -0,0 +1,102 @@ +"""Tests for Cube adapter - roundtrip.""" + +import tempfile +from pathlib import Path + +from sidemantic.adapters.cube import CubeAdapter + +from ..helpers import ( + assert_dimension_equivalent, + assert_graph_equivalent, + assert_metric_equivalent, + assert_segment_equivalent, +) + + +def test_cube_to_sidemantic_to_cube_roundtrip(): + """Test that Cube -> Sidemantic -> Cube preserves structure.""" + cube_adapter = CubeAdapter() + graph1 = cube_adapter.parse("tests/fixtures/cube/orders.yml") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + cube_adapter.export(graph1, temp_path) + graph2 = cube_adapter.parse(temp_path) + + # NOTE: check_relationships=False because Cube exporter doesn't export joins yet + # TODO: Fix CubeAdapter.export() to include joins section + assert_graph_equivalent(graph1, graph2, check_relationships=False) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_cube_roundtrip_dimension_properties(): + """Test that dimension properties survive Cube roundtrip.""" + adapter = CubeAdapter() + graph1 = adapter.parse("tests/fixtures/cube/orders.yml") + orders1 = graph1.models["orders"] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + orders2 = graph2.models["orders"] + + for dim1 in orders1.dimensions: + dim2 = orders2.get_dimension(dim1.name) + assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" + assert_dimension_equivalent(dim1, dim2) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_cube_roundtrip_metric_properties(): + """Test that metric properties survive Cube roundtrip.""" + adapter = CubeAdapter() + graph1 = adapter.parse("tests/fixtures/cube/orders.yml") + orders1 = graph1.models["orders"] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + orders2 = graph2.models["orders"] + + for m1 in orders1.metrics: + m2 = orders2.get_metric(m1.name) + assert m2 is not None, f"Metric {m1.name} missing after roundtrip" + assert_metric_equivalent(m1, m2) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_cube_roundtrip_segment_properties(): + """Test that segment properties survive Cube roundtrip.""" + adapter = CubeAdapter() + graph1 = adapter.parse("tests/fixtures/cube/orders.yml") + orders1 = graph1.models["orders"] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + orders2 = graph2.models["orders"] + + for seg1 in orders1.segments: + seg2 = orders2.get_segment(seg1.name) + assert seg2 is not None, f"Segment {seg1.name} missing after roundtrip" + assert_segment_equivalent(seg1, seg2) + + finally: + temp_path.unlink(missing_ok=True) diff --git a/tests/adapters/hex/__init__.py b/tests/adapters/hex/__init__.py new file mode 100644 index 00000000..f8b6b45e --- /dev/null +++ b/tests/adapters/hex/__init__.py @@ -0,0 +1 @@ +"""Tests for Hex adapter.""" diff --git a/tests/adapters/test_hex_roundtrip.py b/tests/adapters/hex/test_conversion.py similarity index 56% rename from tests/adapters/test_hex_roundtrip.py rename to tests/adapters/hex/test_conversion.py index a18b6d29..4f4304ca 100644 --- a/tests/adapters/test_hex_roundtrip.py +++ b/tests/adapters/hex/test_conversion.py @@ -1,4 +1,4 @@ -"""Tests for Hex adapter parsing.""" +"""Tests for Hex adapter - cross-format conversion.""" import tempfile from pathlib import Path @@ -9,35 +9,6 @@ from sidemantic.adapters.hex import HexAdapter from sidemantic.adapters.lookml import LookMLAdapter from sidemantic.adapters.metricflow import MetricFlowAdapter -from tests.adapters.helpers import ( - assert_dimension_equivalent, - assert_graph_equivalent, - assert_metric_equivalent, -) - - -def test_hex_to_sidemantic_to_hex_roundtrip(): - """Test that Hex -> Sidemantic -> Hex preserves structure.""" - # Import from Hex - hex_adapter = HexAdapter() - graph1 = hex_adapter.parse("tests/fixtures/hex/orders.yml") - - # Export back to Hex - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - hex_adapter.export(graph1, temp_path) - - # Re-import and verify - graph2 = hex_adapter.parse(temp_path) - - # Verify semantic equivalence - # NOTE: Hex doesn't have native relationships or segments - assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) - - finally: - temp_path.unlink(missing_ok=True) def test_hex_to_cube_conversion(): @@ -168,75 +139,5 @@ def test_hex_to_lookml_conversion(): temp_path.unlink(missing_ok=True) -def test_roundtrip_real_hex_example(): - """Test Hex example roundtrip using actual example files.""" - adapter = HexAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/hex/orders.yml") - - # Export - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - - # Import exported version - graph2 = adapter.parse(temp_path) - - # Verify semantic equivalence - assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_hex_roundtrip_dimension_properties(): - """Test that dimension properties survive Hex roundtrip.""" - adapter = HexAdapter() - graph1 = adapter.parse("tests/fixtures/hex/orders.yml") - orders1 = graph1.models["orders"] - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - orders2 = graph2.models["orders"] - - for dim1 in orders1.dimensions: - dim2 = orders2.get_dimension(dim1.name) - assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" - assert_dimension_equivalent(dim1, dim2) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_hex_roundtrip_metric_properties(): - """Test that metric properties survive Hex roundtrip.""" - adapter = HexAdapter() - graph1 = adapter.parse("tests/fixtures/hex/orders.yml") - orders1 = graph1.models["orders"] - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - orders2 = graph2.models["orders"] - - for m1 in orders1.metrics: - m2 = orders2.get_metric(m1.name) - assert m2 is not None, f"Metric {m1.name} missing after roundtrip" - assert_metric_equivalent(m1, m2) - - finally: - temp_path.unlink(missing_ok=True) - - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_hex.py b/tests/adapters/hex/test_parsing.py similarity index 76% rename from tests/adapters/test_hex.py rename to tests/adapters/hex/test_parsing.py index 85d7f93e..8ea57170 100644 --- a/tests/adapters/test_hex.py +++ b/tests/adapters/hex/test_parsing.py @@ -1,4 +1,4 @@ -"""Tests for Hex adapter parsing.""" +"""Tests for Hex adapter - parsing.""" import pytest @@ -83,30 +83,5 @@ def test_import_hex_calculated_dimensions(): assert "IF" in annual_price.sql -def test_query_imported_hex_example(): - """Test that we can compile queries from imported Hex schema.""" - from sidemantic import SemanticLayer - - adapter = HexAdapter() - graph = adapter.parse("tests/fixtures/hex/orders.yml") - - layer = SemanticLayer() - layer.graph = graph - - # Test basic metric query - sql = layer.compile(metrics=["orders.revenue"]) - assert "SUM" in sql.upper() - - # Test with dimension - sql = layer.compile(metrics=["orders.revenue", "orders.order_count"], dimensions=["orders.status"]) - assert "GROUP BY" in sql.upper() - assert "status" in sql.lower() - - # Test with filter - sql = layer.compile(metrics=["orders.revenue"], filters=["orders.status = 'completed'"]) - assert "WHERE" in sql.upper() - assert "completed" in sql.lower() - - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/adapters/hex/test_query.py b/tests/adapters/hex/test_query.py new file mode 100644 index 00000000..c289addd --- /dev/null +++ b/tests/adapters/hex/test_query.py @@ -0,0 +1,34 @@ +"""Tests for Hex adapter - query compilation.""" + +import pytest + +from sidemantic.adapters.hex import HexAdapter + + +def test_query_imported_hex_example(): + """Test that we can compile queries from imported Hex schema.""" + from sidemantic import SemanticLayer + + adapter = HexAdapter() + graph = adapter.parse("tests/fixtures/hex/orders.yml") + + layer = SemanticLayer() + layer.graph = graph + + # Test basic metric query + sql = layer.compile(metrics=["orders.revenue"]) + assert "SUM" in sql.upper() + + # Test with dimension + sql = layer.compile(metrics=["orders.revenue", "orders.order_count"], dimensions=["orders.status"]) + assert "GROUP BY" in sql.upper() + assert "status" in sql.lower() + + # Test with filter + sql = layer.compile(metrics=["orders.revenue"], filters=["orders.status = 'completed'"]) + assert "WHERE" in sql.upper() + assert "completed" in sql.lower() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/hex/test_roundtrip.py b/tests/adapters/hex/test_roundtrip.py new file mode 100644 index 00000000..21db1819 --- /dev/null +++ b/tests/adapters/hex/test_roundtrip.py @@ -0,0 +1,112 @@ +"""Tests for Hex adapter - roundtrip.""" + +import tempfile +from pathlib import Path + +import pytest + +from sidemantic.adapters.hex import HexAdapter + +from ..helpers import ( + assert_dimension_equivalent, + assert_graph_equivalent, + assert_metric_equivalent, +) + + +def test_hex_to_sidemantic_to_hex_roundtrip(): + """Test that Hex -> Sidemantic -> Hex preserves structure.""" + # Import from Hex + hex_adapter = HexAdapter() + graph1 = hex_adapter.parse("tests/fixtures/hex/orders.yml") + + # Export back to Hex + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + hex_adapter.export(graph1, temp_path) + + # Re-import and verify + graph2 = hex_adapter.parse(temp_path) + + # Verify semantic equivalence + # NOTE: Hex doesn't have native relationships or segments + assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_roundtrip_real_hex_example(): + """Test Hex example roundtrip using actual example files.""" + adapter = HexAdapter() + + # Import original + graph1 = adapter.parse("tests/fixtures/hex/orders.yml") + + # Export + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + + # Import exported version + graph2 = adapter.parse(temp_path) + + # Verify semantic equivalence + assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_hex_roundtrip_dimension_properties(): + """Test that dimension properties survive Hex roundtrip.""" + adapter = HexAdapter() + graph1 = adapter.parse("tests/fixtures/hex/orders.yml") + orders1 = graph1.models["orders"] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + orders2 = graph2.models["orders"] + + for dim1 in orders1.dimensions: + dim2 = orders2.get_dimension(dim1.name) + assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" + assert_dimension_equivalent(dim1, dim2) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_hex_roundtrip_metric_properties(): + """Test that metric properties survive Hex roundtrip.""" + adapter = HexAdapter() + graph1 = adapter.parse("tests/fixtures/hex/orders.yml") + orders1 = graph1.models["orders"] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + orders2 = graph2.models["orders"] + + for m1 in orders1.metrics: + m2 = orders2.get_metric(m1.name) + assert m2 is not None, f"Metric {m1.name} missing after roundtrip" + assert_metric_equivalent(m1, m2) + + finally: + temp_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/lookml/__init__.py b/tests/adapters/lookml/__init__.py new file mode 100644 index 00000000..7d852564 --- /dev/null +++ b/tests/adapters/lookml/__init__.py @@ -0,0 +1 @@ +"""Tests for LookML adapter.""" diff --git a/tests/adapters/lookml/test_conversion.py b/tests/adapters/lookml/test_conversion.py new file mode 100644 index 00000000..87211851 --- /dev/null +++ b/tests/adapters/lookml/test_conversion.py @@ -0,0 +1,90 @@ +"""Tests for LookML adapter - cross-format conversion.""" + +import tempfile +from pathlib import Path + +import pytest + +from sidemantic.adapters.cube import CubeAdapter +from sidemantic.adapters.lookml import LookMLAdapter +from sidemantic.adapters.metricflow import MetricFlowAdapter + +# ============================================================================= +# CROSS-FORMAT CONVERSION TESTS +# ============================================================================= + + +def test_lookml_to_cube_conversion(): + """Test converting LookML format to Cube format.""" + # Import from LookML + lookml_adapter = LookMLAdapter() + graph = lookml_adapter.parse("tests/fixtures/lookml/orders.lkml") + + # Export to Cube + cube_adapter = CubeAdapter() + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + cube_adapter.export(graph, temp_path) + + # Re-import as Cube and verify structure + graph2 = cube_adapter.parse(temp_path) + + assert "orders" in graph2.models + orders = graph2.models["orders"] + + # Verify dimensions converted + dim_names = [d.name for d in orders.dimensions] + assert "status" in dim_names + + # Verify measures converted + measure_names = [m.name for m in orders.metrics] + assert "revenue" in measure_names + + # Verify segments preserved + segment_names = [s.name for s in orders.segments] + assert "high_value" in segment_names + + finally: + temp_path.unlink(missing_ok=True) + + +def test_lookml_to_metricflow_conversion(): + """Test converting LookML format to MetricFlow format.""" + # Import from LookML + lookml_adapter = LookMLAdapter() + graph = lookml_adapter.parse("tests/fixtures/lookml/orders.lkml") + + # Export to MetricFlow + mf_adapter = MetricFlowAdapter() + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + mf_adapter.export(graph, temp_path) + + # Re-import as MetricFlow and verify structure + graph2 = mf_adapter.parse(temp_path) + + assert "orders" in graph2.models + orders = graph2.models["orders"] + + # Verify dimensions converted + dim_names = [d.name for d in orders.dimensions] + assert "status" in dim_names + + # Verify measures converted + measure_names = [m.name for m in orders.metrics] + assert "revenue" in measure_names + + # Verify segments stored in meta (MetricFlow doesn't have native support) + if orders.segments: + assert len(orders.segments) > 0 + + finally: + temp_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_lookml.py b/tests/adapters/lookml/test_parsing.py similarity index 88% rename from tests/adapters/test_lookml.py rename to tests/adapters/lookml/test_parsing.py index 668ab017..1d5590cf 100644 --- a/tests/adapters/test_lookml.py +++ b/tests/adapters/lookml/test_parsing.py @@ -1,9 +1,18 @@ -"""Tests for LookML adapter.""" +"""Tests for LookML adapter - parsing.""" +import shutil +import tempfile from pathlib import Path +import lkml +import pytest + from sidemantic.adapters.lookml import LookMLAdapter +# ============================================================================= +# PARSING TESTS +# ============================================================================= + def test_lookml_adapter_basic(): """Test LookML adapter with basic orders example.""" @@ -248,9 +257,6 @@ def test_lookml_adapter_explores(): adapter = LookMLAdapter() # Create a temporary directory with just the files we need - import shutil - import tempfile - with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) @@ -283,9 +289,6 @@ def test_lookml_adapter_explores_multi_join(): adapter = LookMLAdapter() # Create a temporary directory with just the ecommerce files - import shutil - import tempfile - with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) @@ -381,8 +384,6 @@ def test_lookml_adapter_export(): graph = adapter.parse(Path("tests/fixtures/lookml/orders.lkml")) # Export to a temporary file - import tempfile - with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: output_path = f.name @@ -831,11 +832,6 @@ def test_lookml_bq_thelook_user_facts(): def test_lookml_period_over_period_import(): """Test importing LookML period_over_period measures as time_comparison metrics.""" - import tempfile - from pathlib import Path - - import lkml - # Create a test LookML file with period_over_period measures lookml_content = { "views": [ @@ -908,11 +904,6 @@ def test_lookml_period_over_period_import(): def test_lookml_period_over_period_export(): """Test exporting time_comparison metrics as LookML period_over_period measures.""" - import tempfile - from pathlib import Path - - import lkml - from sidemantic.core.dimension import Dimension from sidemantic.core.metric import Metric from sidemantic.core.model import Model @@ -989,3 +980,115 @@ def test_lookml_period_over_period_export(): assert revenue_wow["based_on"] == "total_revenue" assert revenue_wow["period"] == "week" assert revenue_wow["kind"] == "difference" + + +def test_import_real_lookml_example(): + """Test importing a real LookML view file.""" + adapter = LookMLAdapter() + graph = adapter.parse("tests/fixtures/lookml/orders.lkml") + + # Verify models loaded + assert "orders" in graph.models + assert "customers" in graph.models + + orders = graph.models["orders"] + + # Verify dimensions + dim_names = [d.name for d in orders.dimensions] + assert "id" in dim_names + assert "status" in dim_names + assert "customer_id" in dim_names + + # Verify time dimensions were created from dimension_group + assert "created_date" in dim_names + + # Verify primary key was detected + assert orders.primary_key == "id" + + # Verify measures + measure_names = [m.name for m in orders.metrics] + assert "count" in measure_names + assert "revenue" in measure_names + assert "completed_revenue" in measure_names + assert "conversion_rate" in measure_names + + # Verify segments (LookML filters) were imported + segment_names = [s.name for s in orders.segments] + assert "high_value" in segment_names + assert "completed" in segment_names + + # Verify segment SQL was converted from ${TABLE} to {model} + high_value_segment = next(s for s in orders.segments if s.name == "high_value") + assert "{model}" in high_value_segment.sql + assert "${TABLE}" not in high_value_segment.sql + + # Verify measure with filter was imported + completed_revenue = next(m for m in orders.metrics if m.name == "completed_revenue") + assert completed_revenue.filters is not None + assert len(completed_revenue.filters) > 0 + + # Verify derived metric (type=number) was detected + conversion_rate = next(m for m in orders.metrics if m.name == "conversion_rate") + assert conversion_rate.type == "derived" + + +def test_import_lookml_derived_table(): + """Test importing LookML view with derived table.""" + adapter = LookMLAdapter() + graph = adapter.parse("tests/fixtures/lookml/derived_tables.lkml") + + # Verify model loaded + assert "customer_summary" in graph.models + summary = graph.models["customer_summary"] + + # Verify derived table SQL was imported + assert summary.sql is not None + assert "SELECT" in summary.sql.upper() + assert "GROUP BY" in summary.sql.upper() + + # Verify dimensions + dim_names = [d.name for d in summary.dimensions] + assert "customer_id" in dim_names + assert "order_count" in dim_names + assert "total_revenue" in dim_names + + # Verify time dimension_group created time dimensions + assert "last_order_date" in dim_names + + # Verify measures + measure_names = [m.name for m in summary.metrics] + assert "total_customers" in measure_names + assert "avg_orders_per_customer" in measure_names + assert "avg_customer_ltv" in measure_names + + +def test_lookml_explore_parsing(): + """Test parsing LookML explore files for relationships.""" + adapter = LookMLAdapter() + # Parse just the orders example files (view + explore) to avoid duplicate models + # The explore file defines relationships between models + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Copy just the orders files + shutil.copy("tests/fixtures/lookml/orders.lkml", tmpdir_path / "orders.lkml") + shutil.copy("tests/fixtures/lookml/orders.explore.lkml", tmpdir_path / "orders.explore.lkml") + + graph = adapter.parse(tmpdir_path) + + # Verify orders model exists + assert "orders" in graph.models + orders = graph.models["orders"] + + # Verify relationship was parsed from explore + assert len(orders.relationships) >= 1 + + # Verify relationship details + customer_rel = next((r for r in orders.relationships if r.name == "customers"), None) + assert customer_rel is not None + assert customer_rel.type == "many_to_one" + assert customer_rel.foreign_key == "customer_id" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/lookml/test_query.py b/tests/adapters/lookml/test_query.py new file mode 100644 index 00000000..397d2613 --- /dev/null +++ b/tests/adapters/lookml/test_query.py @@ -0,0 +1,54 @@ +"""Tests for LookML adapter - query compilation.""" + +import pytest + +from sidemantic.adapters.lookml import LookMLAdapter + +# ============================================================================= +# QUERY TESTS +# ============================================================================= + + +def test_query_imported_lookml_example(): + """Test that we can compile queries from imported LookML schema.""" + from sidemantic import SemanticLayer + + adapter = LookMLAdapter() + graph = adapter.parse("tests/fixtures/lookml/orders.lkml") + + layer = SemanticLayer() + layer.graph = graph + + # Test basic metric query + sql = layer.compile(metrics=["orders.revenue"]) + assert "SUM" in sql.upper() + + # Test with dimension + sql = layer.compile(metrics=["orders.revenue", "orders.count"], dimensions=["orders.status"]) + assert "GROUP BY" in sql.upper() + assert "status" in sql.lower() + + # Test with segment + sql = layer.compile(metrics=["orders.revenue"], segments=["orders.completed"]) + assert "WHERE" in sql.upper() + assert "status" in sql.lower() + + +def test_query_with_time_dimension_lookml(): + """Test querying time dimensions from LookML import.""" + from sidemantic import SemanticLayer + + adapter = LookMLAdapter() + graph = adapter.parse("tests/fixtures/lookml/orders.lkml") + + layer = SemanticLayer() + layer.graph = graph + + # Query with time dimension + sql = layer.compile(metrics=["orders.revenue"], dimensions=["orders.created_date"]) + assert "created_at" in sql.lower() + assert "GROUP BY" in sql.upper() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/lookml/test_roundtrip.py b/tests/adapters/lookml/test_roundtrip.py new file mode 100644 index 00000000..a30874d7 --- /dev/null +++ b/tests/adapters/lookml/test_roundtrip.py @@ -0,0 +1,140 @@ +"""Tests for LookML adapter - roundtrip.""" + +import tempfile +from pathlib import Path + +import pytest + +from sidemantic.adapters.lookml import LookMLAdapter + +from ..helpers import ( + assert_dimension_equivalent, + assert_graph_equivalent, + assert_metric_equivalent, + assert_segment_equivalent, +) + +# ============================================================================= +# ROUNDTRIP TESTS +# ============================================================================= + + +def test_lookml_to_sidemantic_to_lookml_roundtrip(): + """Test that LookML -> Sidemantic -> LookML preserves structure.""" + # Import from LookML + lookml_adapter = LookMLAdapter() + graph1 = lookml_adapter.parse("tests/fixtures/lookml/orders.lkml") + + # Export back to LookML + with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: + temp_path = Path(f.name) + + try: + lookml_adapter.export(graph1, temp_path) + + # Re-import and verify + graph2 = lookml_adapter.parse(temp_path) + + # Verify semantic equivalence + # NOTE: LookML relationships come from explore files, not view files + assert_graph_equivalent(graph1, graph2, check_relationships=False) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_roundtrip_real_lookml_example(): + """Test LookML example roundtrip using the actual example file.""" + adapter = LookMLAdapter() + + # Import original + graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") + + # Export + with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + + # Import exported version + graph2 = adapter.parse(temp_path) + + # Verify semantic equivalence + assert_graph_equivalent(graph1, graph2, check_relationships=False) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_lookml_roundtrip_dimension_properties(): + """Test that dimension properties survive LookML roundtrip.""" + adapter = LookMLAdapter() + graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + + for model_name, model1 in graph1.models.items(): + model2 = graph2.models[model_name] + for dim1 in model1.dimensions: + dim2 = model2.get_dimension(dim1.name) + assert dim2 is not None, f"Dimension {model_name}.{dim1.name} missing after roundtrip" + assert_dimension_equivalent(dim1, dim2) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_lookml_roundtrip_metric_properties(): + """Test that metric properties survive LookML roundtrip.""" + adapter = LookMLAdapter() + graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + + for model_name, model1 in graph1.models.items(): + model2 = graph2.models[model_name] + for m1 in model1.metrics: + m2 = model2.get_metric(m1.name) + assert m2 is not None, f"Metric {model_name}.{m1.name} missing after roundtrip" + assert_metric_equivalent(m1, m2) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_lookml_roundtrip_segment_properties(): + """Test that segment properties survive LookML roundtrip.""" + adapter = LookMLAdapter() + graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + + for model_name, model1 in graph1.models.items(): + model2 = graph2.models[model_name] + for seg1 in model1.segments: + seg2 = model2.get_segment(seg1.name) + assert seg2 is not None, f"Segment {model_name}.{seg1.name} missing after roundtrip" + assert_segment_equivalent(seg1, seg2) + + finally: + temp_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/metricflow/__init__.py b/tests/adapters/metricflow/__init__.py new file mode 100644 index 00000000..ad41b286 --- /dev/null +++ b/tests/adapters/metricflow/__init__.py @@ -0,0 +1 @@ +"""Tests for MetricFlow adapter.""" diff --git a/tests/adapters/test_export_adapters.py b/tests/adapters/metricflow/test_conversion.py similarity index 66% rename from tests/adapters/test_export_adapters.py rename to tests/adapters/metricflow/test_conversion.py index 13d703a1..124ba680 100644 --- a/tests/adapters/test_export_adapters.py +++ b/tests/adapters/metricflow/test_conversion.py @@ -1,4 +1,4 @@ -"""Test export adapters for Cube and MetricFlow.""" +"""Tests for MetricFlow adapter - conversion between formats.""" import tempfile from pathlib import Path @@ -10,12 +10,16 @@ from sidemantic.adapters.metricflow import MetricFlowAdapter from sidemantic.adapters.sidemantic import SidemanticAdapter +# ============================================================================= +# CROSS-FORMAT CONVERSION TESTS +# ============================================================================= -def test_cube_export(): - """Test export to Cube format.""" - # Load native format - native_adapter = SidemanticAdapter() - graph = native_adapter.parse("tests/fixtures/sidemantic/orders.yml") + +def test_metricflow_to_cube_conversion(): + """Test converting MetricFlow format to Cube format.""" + # Import from MetricFlow + mf_adapter = MetricFlowAdapter() + graph = mf_adapter.parse("tests/fixtures/metricflow/semantic_models.yml") # Export to Cube cube_adapter = CubeAdapter() @@ -25,30 +29,33 @@ def test_cube_export(): try: cube_adapter.export(graph, temp_path) - # Verify file structure - with open(temp_path) as f: - data = yaml.safe_load(f) + # Re-import as Cube and verify structure + graph2 = cube_adapter.parse(temp_path) - assert "cubes" in data - assert len(data["cubes"]) == 2 + assert "orders" in graph2.models + assert "customers" in graph2.models - # Verify orders cube - orders_cube = next(c for c in data["cubes"] if c["name"] == "orders") - assert orders_cube["sql_table"] == "public.orders" - assert "dimensions" in orders_cube - assert "measures" in orders_cube - # Note: joins only exported when foreign entity name matches target model name + orders = graph2.models["orders"] - # Verify round-trip (parse exported file) - graph2 = cube_adapter.parse(temp_path) - assert len(graph2.models) == 2 + # Verify dimensions converted + dim_names = [d.name for d in orders.dimensions] + assert "status" in dim_names + + # Verify measures converted + measure_names = [m.name for m in orders.metrics] + assert "revenue" in measure_names finally: temp_path.unlink(missing_ok=True) -def test_metricflow_export(): - """Test export to MetricFlow format.""" +# ============================================================================= +# SIDEMANTIC CONVERSION TESTS +# ============================================================================= + + +def test_sidemantic_to_metricflow_export(): + """Test export from Sidemantic to MetricFlow format.""" # Load native format native_adapter = SidemanticAdapter() graph = native_adapter.parse("tests/fixtures/sidemantic/orders.yml") @@ -101,36 +108,7 @@ def test_metricflow_export(): temp_path.unlink(missing_ok=True) -def test_cube_round_trip(): - """Test Sidemantic -> Cube -> Sidemantic round-trip.""" - # Load native - native_adapter = SidemanticAdapter() - graph = native_adapter.parse("tests/fixtures/sidemantic/orders.yml") - - # Export to Cube - cube_adapter = CubeAdapter() - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - cube_path = Path(f.name) - - try: - cube_adapter.export(graph, cube_path) - - # Import from Cube - graph2 = cube_adapter.parse(cube_path) - - # Verify structure preserved - assert set(graph2.models.keys()) == set(graph.models.keys()) - - # Verify measures preserved - orders1 = graph.models["orders"] - orders2 = graph2.models["orders"] - assert len(orders1.metrics) == len(orders2.metrics) - - finally: - cube_path.unlink(missing_ok=True) - - -def test_metricflow_round_trip(): +def test_sidemantic_to_metricflow_roundtrip(): """Test Sidemantic -> MetricFlow -> Sidemantic round-trip.""" # Load native native_adapter = SidemanticAdapter() diff --git a/tests/adapters/test_metricflow.py b/tests/adapters/metricflow/test_parsing.py similarity index 93% rename from tests/adapters/test_metricflow.py rename to tests/adapters/metricflow/test_parsing.py index 1b03ef4e..8d73f3ba 100644 --- a/tests/adapters/test_metricflow.py +++ b/tests/adapters/metricflow/test_parsing.py @@ -1,8 +1,15 @@ -"""Tests for MetricFlow adapter.""" +"""Tests for MetricFlow adapter - parsing.""" from pathlib import Path +import pytest + from sidemantic.adapters.metricflow import MetricFlowAdapter +from sidemantic.sql.generator import SQLGenerator + +# ============================================================================= +# PARSING TESTS +# ============================================================================= def test_metricflow_adapter(): @@ -75,8 +82,6 @@ def test_metricflow_adapter_join_discovery(): assert customer_rel.type == "many_to_one" # Verify that queries can now build join paths - from sidemantic.sql.generator import SQLGenerator - generator = SQLGenerator(graph) # This query should work now that relationships are resolved @@ -781,3 +786,54 @@ def test_metricflow_saved_queries(): assert "sales_transactions" in graph.metrics transactions = graph.get_metric("sales_transactions") assert transactions.sql == "sales_count" + + +def test_import_real_metricflow_example(): + """Test importing a real dbt MetricFlow schema file.""" + adapter = MetricFlowAdapter() + graph = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") + + # Verify models loaded + assert "orders" in graph.models + assert "customers" in graph.models + + orders = graph.models["orders"] + customers = graph.models["customers"] + + # Verify dimensions + order_dims = [d.name for d in orders.dimensions] + assert "order_date" in order_dims + assert "status" in order_dims + + customer_dims = [d.name for d in customers.dimensions] + assert "region" in customer_dims + assert "tier" in customer_dims + + # Verify measures + measure_names = [m.name for m in orders.metrics] + assert "order_count" in measure_names + assert "revenue" in measure_names + assert "avg_order_value" in measure_names + + # Verify relationships were created from entities (resolved to model names) + rel_names = [r.name for r in orders.relationships] + assert "customers" in rel_names + customer_rel = next(r for r in orders.relationships if r.name == "customers") + assert customer_rel.type == "many_to_one" + assert customer_rel.foreign_key == "customer_id" + + # Verify graph-level metrics + assert "total_revenue" in graph.metrics + assert "average_order_value" in graph.metrics + + total_revenue = graph.metrics["total_revenue"] + assert total_revenue.type is None # Simple metric maps to untyped + + avg_order = graph.metrics["average_order_value"] + assert avg_order.type == "ratio" + assert avg_order.numerator == "revenue" + assert avg_order.denominator == "order_count" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/metricflow/test_query.py b/tests/adapters/metricflow/test_query.py new file mode 100644 index 00000000..c179439e --- /dev/null +++ b/tests/adapters/metricflow/test_query.py @@ -0,0 +1,69 @@ +"""Tests for MetricFlow adapter - query generation.""" + +import pytest + +from sidemantic import SemanticLayer +from sidemantic.adapters.metricflow import MetricFlowAdapter + +# ============================================================================= +# QUERY TESTS +# ============================================================================= + + +def test_query_imported_metricflow_example(): + """Test that we can compile queries from imported MetricFlow schema.""" + adapter = MetricFlowAdapter() + graph = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") + + layer = SemanticLayer() + layer.graph = graph + + # Test basic measure query + sql = layer.compile(metrics=["orders.revenue"]) + assert "SUM" in sql.upper() + + # Test with dimension + sql = layer.compile(metrics=["orders.revenue", "orders.order_count"], dimensions=["orders.status"]) + assert "GROUP BY" in sql.upper() + assert "status" in sql.lower() + + # Test cross-model query (only if join path exists) + # Note: MetricFlow entities may not map 1:1 to model names + try: + sql = layer.compile(metrics=["orders.revenue"], dimensions=["customers.region"]) + assert "JOIN" in sql.upper() + assert "customers" in sql.lower() + except Exception: + # Join path not configured, which is expected for some imports + pass + + # Test graph-level ratio metric (if it exists and is queryable) + if "average_order_value" in graph.metrics: + avg_metric = graph.metrics["average_order_value"] + # Ratio metrics should have numerator/denominator set + if avg_metric.type == "ratio" and avg_metric.numerator and avg_metric.denominator: + try: + sql = layer.compile(metrics=["average_order_value"]) + assert sql # Should generate valid SQL with ratio calculation + except ValueError: + # Some graph-level metrics may need model context to be queryable + pass + + +def test_query_with_filter_metricflow(): + """Test that metric filters work from MetricFlow import.""" + adapter = MetricFlowAdapter() + graph = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") + + layer = SemanticLayer() + layer.graph = graph + + # Query with filter + sql = layer.compile(metrics=["orders.revenue"], filters=["orders.status = 'completed'"]) + assert "WHERE" in sql.upper() + assert "status" in sql.lower() + assert "completed" in sql.lower() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/metricflow/test_roundtrip.py b/tests/adapters/metricflow/test_roundtrip.py new file mode 100644 index 00000000..0cef369a --- /dev/null +++ b/tests/adapters/metricflow/test_roundtrip.py @@ -0,0 +1,143 @@ +"""Tests for MetricFlow adapter - roundtrip.""" + +import tempfile +from pathlib import Path + +import pytest + +from sidemantic.adapters.metricflow import MetricFlowAdapter + +from ..helpers import ( + assert_dimension_equivalent, + assert_graph_equivalent, + assert_metric_equivalent, +) + +# ============================================================================= +# ROUNDTRIP TESTS +# ============================================================================= + + +def test_metricflow_to_sidemantic_to_metricflow_roundtrip(): + """Test that MetricFlow -> Sidemantic -> MetricFlow preserves structure.""" + # Import from MetricFlow + mf_adapter = MetricFlowAdapter() + graph = mf_adapter.parse("tests/fixtures/metricflow/semantic_models.yml") + + # Export back to MetricFlow + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + mf_adapter.export(graph, temp_path) + + # Re-import and verify + graph2 = mf_adapter.parse(temp_path) + + # Verify models preserved + assert "orders" in graph2.models + assert "customers" in graph2.models + + # Verify metrics preserved + # Note: Simple metrics that just reference measures may not round-trip + # since they can be queried directly via the measure + assert "average_order_value" in graph2.metrics + + # Verify metric types preserved + avg_order = graph2.metrics["average_order_value"] + assert avg_order.type == "ratio" + + # total_revenue is a simple metric, may not be preserved in export + # (can be queried directly as orders.revenue) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_roundtrip_real_metricflow_example(): + """Test MetricFlow example roundtrip using the actual example file.""" + adapter = MetricFlowAdapter() + + # Import original + graph1 = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") + + # Export + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + + # Import exported version + graph2 = adapter.parse(temp_path) + + # Verify semantic equivalence + # NOTE: MetricFlow entities create relationships that may not fully round-trip + # NOTE: MetricFlow uses ref() syntax which doesn't preserve schema prefixes + assert_graph_equivalent(graph1, graph2, check_relationships=False, check_table_schema=False) + + # Verify graph-level metrics preserved + # Note: Simple metrics may not round-trip, so we just check that ratio metrics are there + ratio_metrics1 = {name for name, m in graph1.metrics.items() if m.type == "ratio"} + ratio_metrics2 = {name for name, m in graph2.metrics.items() if m.type == "ratio"} + assert ratio_metrics1 == ratio_metrics2 + + # Verify ratio metric properties preserved + for name in ratio_metrics1: + m1 = graph1.metrics[name] + m2 = graph2.metrics[name] + assert m1.numerator == m2.numerator, f"Metric {name}: numerator mismatch" + assert m1.denominator == m2.denominator, f"Metric {name}: denominator mismatch" + + finally: + temp_path.unlink(missing_ok=True) + + +def test_metricflow_roundtrip_dimension_properties(): + """Test that dimension properties survive MetricFlow roundtrip.""" + adapter = MetricFlowAdapter() + graph1 = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + + for model_name, model1 in graph1.models.items(): + model2 = graph2.models[model_name] + for dim1 in model1.dimensions: + dim2 = model2.get_dimension(dim1.name) + assert dim2 is not None, f"Dimension {model_name}.{dim1.name} missing after roundtrip" + assert_dimension_equivalent(dim1, dim2) + + finally: + temp_path.unlink(missing_ok=True) + + +def test_metricflow_roundtrip_metric_properties(): + """Test that metric properties survive MetricFlow roundtrip.""" + adapter = MetricFlowAdapter() + graph1 = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph1, temp_path) + graph2 = adapter.parse(temp_path) + + for model_name, model1 in graph1.models.items(): + model2 = graph2.models[model_name] + for m1 in model1.metrics: + m2 = model2.get_metric(m1.name) + assert m2 is not None, f"Metric {model_name}.{m1.name} missing after roundtrip" + assert_metric_equivalent(m1, m2) + + finally: + temp_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/omni/__init__.py b/tests/adapters/omni/__init__.py new file mode 100644 index 00000000..1e52c797 --- /dev/null +++ b/tests/adapters/omni/__init__.py @@ -0,0 +1 @@ +"""Tests for Omni adapter.""" diff --git a/tests/adapters/test_omni_roundtrip.py b/tests/adapters/omni/test_conversion.py similarity index 62% rename from tests/adapters/test_omni_roundtrip.py rename to tests/adapters/omni/test_conversion.py index c59902be..9738624b 100644 --- a/tests/adapters/test_omni_roundtrip.py +++ b/tests/adapters/omni/test_conversion.py @@ -1,4 +1,4 @@ -"""Tests for Omni adapter parsing.""" +"""Tests for Omni adapter - cross-format conversion.""" import tempfile from pathlib import Path @@ -11,31 +11,6 @@ from sidemantic.adapters.omni import OmniAdapter from sidemantic.adapters.rill import RillAdapter from sidemantic.adapters.superset import SupersetAdapter -from tests.adapters.helpers import ( - assert_dimension_equivalent, - assert_graph_equivalent, - assert_metric_equivalent, -) - - -def test_omni_to_sidemantic_to_omni_roundtrip(): - """Test roundtrip: Omni → Sidemantic → Omni.""" - adapter = OmniAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/omni/") - - # Export - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - - # Import exported version - graph2 = adapter.parse(output_path) - - # Verify semantic equivalence - # NOTE: Omni doesn't have native segments - assert_graph_equivalent(graph1, graph2, check_segments=False) def test_omni_to_cube_conversion(): @@ -143,41 +118,5 @@ def test_omni_to_rill_conversion(): assert (output_path / "orders.yaml").exists() -def test_omni_roundtrip_dimension_properties(): - """Test that dimension properties survive Omni roundtrip.""" - adapter = OmniAdapter() - graph1 = adapter.parse("tests/fixtures/omni/") - orders1 = graph1.models["orders"] - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - graph2 = adapter.parse(output_path) - orders2 = graph2.models["orders"] - - for dim1 in orders1.dimensions: - dim2 = orders2.get_dimension(dim1.name) - assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" - assert_dimension_equivalent(dim1, dim2) - - -def test_omni_roundtrip_metric_properties(): - """Test that metric properties survive Omni roundtrip.""" - adapter = OmniAdapter() - graph1 = adapter.parse("tests/fixtures/omni/") - orders1 = graph1.models["orders"] - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - graph2 = adapter.parse(output_path) - orders2 = graph2.models["orders"] - - for m1 in orders1.metrics: - m2 = orders2.get_metric(m1.name) - assert m2 is not None, f"Metric {m1.name} missing after roundtrip" - assert_metric_equivalent(m1, m2) - - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_omni.py b/tests/adapters/omni/test_parsing.py similarity index 95% rename from tests/adapters/test_omni.py rename to tests/adapters/omni/test_parsing.py index e828a325..bb2fbcf3 100644 --- a/tests/adapters/test_omni.py +++ b/tests/adapters/omni/test_parsing.py @@ -1,8 +1,16 @@ -"""Tests for Omni adapter parsing.""" +"""Tests for Omni adapter - parsing.""" + +import tempfile +from pathlib import Path import pytest +import yaml from sidemantic.adapters.omni import OmniAdapter +from sidemantic.core.dimension import Dimension +from sidemantic.core.metric import Metric +from sidemantic.core.model import Model +from sidemantic.core.semantic_graph import SemanticGraph def test_import_real_omni_example(): @@ -86,11 +94,6 @@ def test_import_omni_model_relationships(): def test_omni_time_comparison_import(): """Test importing Omni time comparison measures (date_offset_from_query).""" - import tempfile - from pathlib import Path - - import yaml - # Create a test Omni view with time comparison measure view_def = { "name": "sales", @@ -153,16 +156,6 @@ def test_omni_time_comparison_import(): def test_omni_time_comparison_export(): """Test exporting time_comparison metrics to Omni format.""" - import tempfile - from pathlib import Path - - import yaml - - from sidemantic.core.dimension import Dimension - from sidemantic.core.metric import Metric - from sidemantic.core.model import Model - from sidemantic.core.semantic_graph import SemanticGraph - # Create a model with time_comparison metric sales = Model( name="sales", diff --git a/tests/adapters/omni/test_roundtrip.py b/tests/adapters/omni/test_roundtrip.py new file mode 100644 index 00000000..ca5dd780 --- /dev/null +++ b/tests/adapters/omni/test_roundtrip.py @@ -0,0 +1,74 @@ +"""Tests for Omni adapter - roundtrip.""" + +import tempfile +from pathlib import Path + +import pytest + +from sidemantic.adapters.omni import OmniAdapter + +from ..helpers import ( + assert_dimension_equivalent, + assert_graph_equivalent, + assert_metric_equivalent, +) + + +def test_omni_to_sidemantic_to_omni_roundtrip(): + """Test roundtrip: Omni -> Sidemantic -> Omni.""" + adapter = OmniAdapter() + + # Import original + graph1 = adapter.parse("tests/fixtures/omni/") + + # Export + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + + # Import exported version + graph2 = adapter.parse(output_path) + + # Verify semantic equivalence + # NOTE: Omni doesn't have native segments + assert_graph_equivalent(graph1, graph2, check_segments=False) + + +def test_omni_roundtrip_dimension_properties(): + """Test that dimension properties survive Omni roundtrip.""" + adapter = OmniAdapter() + graph1 = adapter.parse("tests/fixtures/omni/") + orders1 = graph1.models["orders"] + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + graph2 = adapter.parse(output_path) + orders2 = graph2.models["orders"] + + for dim1 in orders1.dimensions: + dim2 = orders2.get_dimension(dim1.name) + assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" + assert_dimension_equivalent(dim1, dim2) + + +def test_omni_roundtrip_metric_properties(): + """Test that metric properties survive Omni roundtrip.""" + adapter = OmniAdapter() + graph1 = adapter.parse("tests/fixtures/omni/") + orders1 = graph1.models["orders"] + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + graph2 = adapter.parse(output_path) + orders2 = graph2.models["orders"] + + for m1 in orders1.metrics: + m2 = orders2.get_metric(m1.name) + assert m2 is not None, f"Metric {m1.name} missing after roundtrip" + assert_metric_equivalent(m1, m2) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/rill/__init__.py b/tests/adapters/rill/__init__.py new file mode 100644 index 00000000..c6afcc32 --- /dev/null +++ b/tests/adapters/rill/__init__.py @@ -0,0 +1 @@ +"""Tests for Rill adapter.""" diff --git a/tests/adapters/test_rill_roundtrip.py b/tests/adapters/rill/test_conversion.py similarity index 63% rename from tests/adapters/test_rill_roundtrip.py rename to tests/adapters/rill/test_conversion.py index f300a8b9..b500af87 100644 --- a/tests/adapters/test_rill_roundtrip.py +++ b/tests/adapters/rill/test_conversion.py @@ -1,41 +1,14 @@ -"""Tests for Rill adapter parsing.""" +"""Tests for cross-format conversion with Rill adapter.""" import tempfile from pathlib import Path -import pytest - from sidemantic.adapters.cube import CubeAdapter from sidemantic.adapters.lookml import LookMLAdapter from sidemantic.adapters.metricflow import MetricFlowAdapter from sidemantic.adapters.omni import OmniAdapter from sidemantic.adapters.rill import RillAdapter from sidemantic.adapters.superset import SupersetAdapter -from tests.adapters.helpers import ( - assert_dimension_equivalent, - assert_graph_equivalent, - assert_metric_equivalent, -) - - -def test_rill_to_sidemantic_to_rill_roundtrip(): - """Test Rill roundtrip conversion.""" - adapter = RillAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") - - # Export - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - - # Import exported version - graph2 = adapter.parse(output_path / "orders.yaml") - - # Verify semantic equivalence - # NOTE: Rill doesn't have native relationships or segments - assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) def test_rill_to_cube_conversion(): @@ -161,25 +134,6 @@ def test_rill_to_lookml_conversion(): temp_path.unlink(missing_ok=True) -def test_roundtrip_real_rill_example(): - """Test Rill example roundtrip using actual example files.""" - adapter = RillAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") - - # Export - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - - # Import exported version - graph2 = adapter.parse(output_path / "orders.yaml") - - # Verify semantic equivalence - assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) - - def test_superset_to_rill_conversion(): """Test converting Superset dataset to Rill.""" superset_adapter = SupersetAdapter() @@ -212,43 +166,3 @@ def test_omni_to_rill_conversion(): # Verify file created assert (output_path / "orders.yaml").exists() - - -def test_rill_roundtrip_dimension_properties(): - """Test that dimension properties survive Rill roundtrip.""" - adapter = RillAdapter() - graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") - orders1 = graph1.models["orders"] - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - graph2 = adapter.parse(output_path / "orders.yaml") - orders2 = graph2.models["orders"] - - for dim1 in orders1.dimensions: - dim2 = orders2.get_dimension(dim1.name) - assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" - assert_dimension_equivalent(dim1, dim2) - - -def test_rill_roundtrip_metric_properties(): - """Test that metric properties survive Rill roundtrip.""" - adapter = RillAdapter() - graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") - orders1 = graph1.models["orders"] - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - graph2 = adapter.parse(output_path / "orders.yaml") - orders2 = graph2.models["orders"] - - for m1 in orders1.metrics: - m2 = orders2.get_metric(m1.name) - assert m2 is not None, f"Metric {m1.name} missing after roundtrip" - assert_metric_equivalent(m1, m2) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_rill.py b/tests/adapters/rill/test_parsing.py similarity index 89% rename from tests/adapters/test_rill.py rename to tests/adapters/rill/test_parsing.py index 6668744b..8babaff1 100644 --- a/tests/adapters/test_rill.py +++ b/tests/adapters/rill/test_parsing.py @@ -1,6 +1,9 @@ """Tests for Rill adapter parsing.""" -import pytest +import tempfile +from pathlib import Path + +import yaml from sidemantic.adapters.rill import RillAdapter @@ -70,32 +73,8 @@ def test_import_rill_with_table_reference(): assert "region" in dim_names -def test_query_imported_rill_example(): - """Test that we can compile queries from imported Rill schema.""" - from sidemantic import SemanticLayer - - adapter = RillAdapter() - graph = adapter.parse("tests/fixtures/rill/orders.yaml") - - layer = SemanticLayer() - layer.graph = graph - - # Simple metric query - sql = layer.compile(metrics=["orders.total_orders"]) - assert "COUNT" in sql.upper() - - # Query with dimension - sql = layer.compile(metrics=["orders.total_revenue"], dimensions=["orders.status"]) - assert "GROUP BY" in sql.upper() - - def test_import_rill_with_window_function(): """Test importing Rill metrics view with window function definition.""" - import tempfile - from pathlib import Path - - import yaml - # Create a Rill YAML with window function rill_yaml = { "type": "metrics_view", @@ -149,11 +128,6 @@ def test_import_rill_with_window_function(): def test_import_rill_format_preset(): """Test importing Rill metrics view with format_preset mapping.""" - import tempfile - from pathlib import Path - - import yaml - # Create a Rill YAML with format presets rill_yaml = { "type": "metrics_view", @@ -198,11 +172,6 @@ def test_import_rill_format_preset(): def test_export_rill_with_window_function(): """Test exporting Sidemantic model with window function to Rill format.""" - import tempfile - from pathlib import Path - - import yaml - from sidemantic import Dimension, Metric, Model from sidemantic.core.semantic_graph import SemanticGraph @@ -250,7 +219,3 @@ def test_export_rill_with_window_function(): assert "window" in rolling_measure assert rolling_measure["window"]["order"] == "order_date" assert rolling_measure["window"]["frame"] == "RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/adapters/rill/test_query.py b/tests/adapters/rill/test_query.py new file mode 100644 index 00000000..185c8c85 --- /dev/null +++ b/tests/adapters/rill/test_query.py @@ -0,0 +1,21 @@ +"""Tests for querying Rill models.""" + +from sidemantic import SemanticLayer +from sidemantic.adapters.rill import RillAdapter + + +def test_query_imported_rill_example(): + """Test that we can compile queries from imported Rill schema.""" + adapter = RillAdapter() + graph = adapter.parse("tests/fixtures/rill/orders.yaml") + + layer = SemanticLayer() + layer.graph = graph + + # Simple metric query + sql = layer.compile(metrics=["orders.total_orders"]) + assert "COUNT" in sql.upper() + + # Query with dimension + sql = layer.compile(metrics=["orders.total_revenue"], dimensions=["orders.status"]) + assert "GROUP BY" in sql.upper() diff --git a/tests/adapters/rill/test_roundtrip.py b/tests/adapters/rill/test_roundtrip.py new file mode 100644 index 00000000..3ba18a47 --- /dev/null +++ b/tests/adapters/rill/test_roundtrip.py @@ -0,0 +1,86 @@ +"""Tests for Rill adapter roundtrip conversion.""" + +import tempfile +from pathlib import Path + +from sidemantic.adapters.rill import RillAdapter +from tests.adapters.helpers import ( + assert_dimension_equivalent, + assert_graph_equivalent, + assert_metric_equivalent, +) + + +def test_rill_to_sidemantic_to_rill_roundtrip(): + """Test Rill roundtrip conversion.""" + adapter = RillAdapter() + + # Import original + graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") + + # Export + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + + # Import exported version + graph2 = adapter.parse(output_path / "orders.yaml") + + # Verify semantic equivalence + # NOTE: Rill doesn't have native relationships or segments + assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) + + +def test_roundtrip_real_rill_example(): + """Test Rill example roundtrip using actual example files.""" + adapter = RillAdapter() + + # Import original + graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") + + # Export + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + + # Import exported version + graph2 = adapter.parse(output_path / "orders.yaml") + + # Verify semantic equivalence + assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) + + +def test_rill_roundtrip_dimension_properties(): + """Test that dimension properties survive Rill roundtrip.""" + adapter = RillAdapter() + graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") + orders1 = graph1.models["orders"] + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + graph2 = adapter.parse(output_path / "orders.yaml") + orders2 = graph2.models["orders"] + + for dim1 in orders1.dimensions: + dim2 = orders2.get_dimension(dim1.name) + assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" + assert_dimension_equivalent(dim1, dim2) + + +def test_rill_roundtrip_metric_properties(): + """Test that metric properties survive Rill roundtrip.""" + adapter = RillAdapter() + graph1 = adapter.parse("tests/fixtures/rill/orders.yaml") + orders1 = graph1.models["orders"] + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + graph2 = adapter.parse(output_path / "orders.yaml") + orders2 = graph2.models["orders"] + + for m1 in orders1.metrics: + m2 = orders2.get_metric(m1.name) + assert m2 is not None, f"Metric {m1.name} missing after roundtrip" + assert_metric_equivalent(m1, m2) diff --git a/tests/adapters/sidemantic_adapter/__init__.py b/tests/adapters/sidemantic_adapter/__init__.py new file mode 100644 index 00000000..7ddba357 --- /dev/null +++ b/tests/adapters/sidemantic_adapter/__init__.py @@ -0,0 +1 @@ +"""Tests for Sidemantic native YAML adapter.""" diff --git a/tests/adapters/test_sidemantic_adapter.py b/tests/adapters/sidemantic_adapter/test_parsing.py similarity index 98% rename from tests/adapters/test_sidemantic_adapter.py rename to tests/adapters/sidemantic_adapter/test_parsing.py index f6233fa8..d15f5e50 100644 --- a/tests/adapters/test_sidemantic_adapter.py +++ b/tests/adapters/sidemantic_adapter/test_parsing.py @@ -1,4 +1,4 @@ -"""Test Sidemantic native YAML adapter.""" +"""Test Sidemantic native YAML adapter parsing and export.""" import tempfile from pathlib import Path diff --git a/tests/adapters/superset/__init__.py b/tests/adapters/superset/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/adapters/test_superset_roundtrip.py b/tests/adapters/superset/test_conversion.py similarity index 62% rename from tests/adapters/test_superset_roundtrip.py rename to tests/adapters/superset/test_conversion.py index 2f5bd002..70400720 100644 --- a/tests/adapters/test_superset_roundtrip.py +++ b/tests/adapters/superset/test_conversion.py @@ -1,4 +1,4 @@ -"""Tests for Superset adapter parsing.""" +"""Tests for Superset adapter - cross-format conversion.""" import tempfile from pathlib import Path @@ -11,31 +11,10 @@ from sidemantic.adapters.omni import OmniAdapter from sidemantic.adapters.rill import RillAdapter from sidemantic.adapters.superset import SupersetAdapter -from tests.adapters.helpers import ( - assert_dimension_equivalent, - assert_graph_equivalent, - assert_metric_equivalent, -) - -def test_superset_to_sidemantic_to_superset_roundtrip(): - """Test roundtrip: Superset → Sidemantic → Superset.""" - adapter = SupersetAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/superset/orders.yaml") - - # Export - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - - # Import exported version - graph2 = adapter.parse(output_path / "orders.yaml") - - # Verify semantic equivalence - # NOTE: Superset doesn't have native relationships or segments - assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) +# ============================================================================= +# CROSS-FORMAT CONVERSION TESTS +# ============================================================================= def test_superset_to_cube_conversion(): @@ -149,41 +128,5 @@ def test_omni_to_superset_conversion(): assert (output_path / "orders.yaml").exists() -def test_superset_roundtrip_dimension_properties(): - """Test that dimension properties survive Superset roundtrip.""" - adapter = SupersetAdapter() - graph1 = adapter.parse("tests/fixtures/superset/orders.yaml") - orders1 = graph1.models["orders"] - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - graph2 = adapter.parse(output_path / "orders.yaml") - orders2 = graph2.models["orders"] - - for dim1 in orders1.dimensions: - dim2 = orders2.get_dimension(dim1.name) - assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" - assert_dimension_equivalent(dim1, dim2) - - -def test_superset_roundtrip_metric_properties(): - """Test that metric properties survive Superset roundtrip.""" - adapter = SupersetAdapter() - graph1 = adapter.parse("tests/fixtures/superset/orders.yaml") - orders1 = graph1.models["orders"] - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - adapter.export(graph1, output_path) - graph2 = adapter.parse(output_path / "orders.yaml") - orders2 = graph2.models["orders"] - - for m1 in orders1.metrics: - m2 = orders2.get_metric(m1.name) - assert m2 is not None, f"Metric {m1.name} missing after roundtrip" - assert_metric_equivalent(m1, m2) - - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_superset.py b/tests/adapters/superset/test_parsing.py similarity index 90% rename from tests/adapters/test_superset.py rename to tests/adapters/superset/test_parsing.py index ee7aad20..a6b3cefd 100644 --- a/tests/adapters/test_superset.py +++ b/tests/adapters/superset/test_parsing.py @@ -1,9 +1,13 @@ -"""Tests for Superset adapter parsing.""" +"""Tests for Superset adapter - parsing.""" import pytest from sidemantic.adapters.superset import SupersetAdapter +# ============================================================================= +# PARSING TESTS +# ============================================================================= + def test_import_real_superset_example(): """Test importing real Superset dataset files.""" diff --git a/tests/adapters/superset/test_roundtrip.py b/tests/adapters/superset/test_roundtrip.py new file mode 100644 index 00000000..3e27f0e0 --- /dev/null +++ b/tests/adapters/superset/test_roundtrip.py @@ -0,0 +1,78 @@ +"""Tests for Superset adapter - roundtrip.""" + +import tempfile +from pathlib import Path + +import pytest + +from sidemantic.adapters.superset import SupersetAdapter + +from ..helpers import ( + assert_dimension_equivalent, + assert_graph_equivalent, + assert_metric_equivalent, +) + +# ============================================================================= +# ROUNDTRIP TESTS +# ============================================================================= + + +def test_superset_to_sidemantic_to_superset_roundtrip(): + """Test roundtrip: Superset -> Sidemantic -> Superset.""" + adapter = SupersetAdapter() + + # Import original + graph1 = adapter.parse("tests/fixtures/superset/orders.yaml") + + # Export + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + + # Import exported version + graph2 = adapter.parse(output_path / "orders.yaml") + + # Verify semantic equivalence + # NOTE: Superset doesn't have native relationships or segments + assert_graph_equivalent(graph1, graph2, check_relationships=False, check_segments=False) + + +def test_superset_roundtrip_dimension_properties(): + """Test that dimension properties survive Superset roundtrip.""" + adapter = SupersetAdapter() + graph1 = adapter.parse("tests/fixtures/superset/orders.yaml") + orders1 = graph1.models["orders"] + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + graph2 = adapter.parse(output_path / "orders.yaml") + orders2 = graph2.models["orders"] + + for dim1 in orders1.dimensions: + dim2 = orders2.get_dimension(dim1.name) + assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" + assert_dimension_equivalent(dim1, dim2) + + +def test_superset_roundtrip_metric_properties(): + """Test that metric properties survive Superset roundtrip.""" + adapter = SupersetAdapter() + graph1 = adapter.parse("tests/fixtures/superset/orders.yaml") + orders1 = graph1.models["orders"] + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) + adapter.export(graph1, output_path) + graph2 = adapter.parse(output_path / "orders.yaml") + orders2 = graph2.models["orders"] + + for m1 in orders1.metrics: + m2 = orders2.get_metric(m1.name) + assert m2 is not None, f"Metric {m1.name} missing after roundtrip" + assert_metric_equivalent(m1, m2) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_cube_roundtrip.py b/tests/adapters/test_cube_roundtrip.py deleted file mode 100644 index 6787983a..00000000 --- a/tests/adapters/test_cube_roundtrip.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Test import/export/roundtrip for Cube adapter.""" - -import tempfile -from pathlib import Path - -import pytest - -from sidemantic.adapters.cube import CubeAdapter -from sidemantic.adapters.hex import HexAdapter -from sidemantic.adapters.lookml import LookMLAdapter -from sidemantic.adapters.metricflow import MetricFlowAdapter -from sidemantic.adapters.omni import OmniAdapter -from sidemantic.adapters.rill import RillAdapter -from sidemantic.adapters.superset import SupersetAdapter -from tests.adapters.helpers import ( - assert_dimension_equivalent, - assert_graph_equivalent, - assert_metric_equivalent, - assert_segment_equivalent, -) - - -def test_import_real_cube_example(): - """Test importing a real Cube.js schema file.""" - adapter = CubeAdapter() - graph = adapter.parse("tests/fixtures/cube/orders.yml") - - # Verify models loaded - assert "orders" in graph.models - orders = graph.models["orders"] - - # Verify dimensions - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - assert "created_at" in dim_names - assert "customer_id" in dim_names - - # Verify measures - measure_names = [m.name for m in orders.metrics] - assert "count" in measure_names - assert "revenue" in measure_names - assert "completed_revenue" in measure_names - assert "conversion_rate" in measure_names - - # Verify segments were imported - segment_names = [s.name for s in orders.segments] - assert "high_value" in segment_names - assert "completed" in segment_names - - # Verify segment SQL was converted from ${CUBE} to {model} - completed_segment = next(s for s in orders.segments if s.name == "completed") - assert "{model}" in completed_segment.sql - assert "${CUBE}" not in completed_segment.sql - - # Verify measure with filter was imported - completed_revenue = next(m for m in orders.metrics if m.name == "completed_revenue") - assert completed_revenue.filters is not None - assert len(completed_revenue.filters) > 0 - - # Verify ratio metric (calculated measure) was detected - conversion_rate = next(m for m in orders.metrics if m.name == "conversion_rate") - assert conversion_rate.type in ["ratio", "derived"] # Detected as complex metric - - -def test_cube_to_sidemantic_to_cube_roundtrip(): - """Test that Cube -> Sidemantic -> Cube preserves structure.""" - # Import from Cube - cube_adapter = CubeAdapter() - graph1 = cube_adapter.parse("tests/fixtures/cube/orders.yml") - - # Export back to Cube - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - cube_adapter.export(graph1, temp_path) - - # Re-import and verify - graph2 = cube_adapter.parse(temp_path) - - # Verify semantic equivalence - # NOTE: check_relationships=False because Cube exporter doesn't export joins yet - # TODO: Fix CubeAdapter.export() to include joins section - assert_graph_equivalent(graph1, graph2, check_relationships=False) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_cube_to_metricflow_conversion(): - """Test converting Cube format to MetricFlow format.""" - # Import from Cube - cube_adapter = CubeAdapter() - graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") - - # Export to MetricFlow - mf_adapter = MetricFlowAdapter() - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - mf_adapter.export(graph, temp_path) - - # Re-import as MetricFlow and verify structure - graph2 = mf_adapter.parse(temp_path) - - assert "orders" in graph2.models - orders = graph2.models["orders"] - - # Verify dimensions converted - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - - # Verify measures converted - measure_names = [m.name for m in orders.metrics] - assert "revenue" in measure_names - - # Verify segments stored in meta - # (MetricFlow doesn't have native segments, but we preserve in meta) - if orders.segments: - assert len(orders.segments) > 0 - - finally: - temp_path.unlink(missing_ok=True) - - -def test_query_imported_cube_example(): - """Test that we can compile queries from imported Cube schema.""" - from sidemantic import SemanticLayer - - adapter = CubeAdapter() - graph = adapter.parse("tests/fixtures/cube/orders.yml") - - layer = SemanticLayer() - layer.graph = graph - - # Test basic metric query - sql = layer.compile(metrics=["orders.revenue"]) - assert "SUM" in sql.upper() - - # Test with dimension - sql = layer.compile(metrics=["orders.revenue", "orders.count"], dimensions=["orders.status"]) - assert "GROUP BY" in sql.upper() - assert "status" in sql.lower() - - # Test with segment - sql = layer.compile(metrics=["orders.revenue"], segments=["orders.completed"]) - assert "WHERE" in sql.upper() - assert "status" in sql.lower() - - # Test ratio metric (if detected as ratio/derived with proper dependencies) - next(m for m in graph.models["orders"].metrics if m.name == "conversion_rate") - # Note: Cube's ${measure} syntax doesn't translate directly to Sidemantic, - # so derived metrics from Cube may not be queryable without modification - # This is expected behavior - the metric was imported but needs manual adjustment - - -def test_query_with_time_dimension_cube(): - """Test querying time dimensions from Cube import.""" - from sidemantic import SemanticLayer - - adapter = CubeAdapter() - graph = adapter.parse("tests/fixtures/cube/orders.yml") - - layer = SemanticLayer() - layer.graph = graph - - # Query with time dimension - sql = layer.compile(metrics=["orders.revenue"], dimensions=["orders.created_at"]) - assert "created_at" in sql.lower() - assert "GROUP BY" in sql.upper() - - -def test_roundtrip_real_cube_example(): - """Test Cube example roundtrip using the actual example file.""" - adapter = CubeAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/cube/orders.yml") - - # Export - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - - # Import exported version - graph2 = adapter.parse(temp_path) - - # Verify semantic equivalence - # NOTE: check_relationships=False because Cube exporter doesn't export joins yet - assert_graph_equivalent(graph1, graph2, check_relationships=False) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_cube_roundtrip_dimension_properties(): - """Test that dimension properties survive Cube roundtrip.""" - adapter = CubeAdapter() - graph1 = adapter.parse("tests/fixtures/cube/orders.yml") - orders1 = graph1.models["orders"] - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - orders2 = graph2.models["orders"] - - # Verify each dimension property individually for better error messages - for dim1 in orders1.dimensions: - dim2 = orders2.get_dimension(dim1.name) - assert dim2 is not None, f"Dimension {dim1.name} missing after roundtrip" - assert_dimension_equivalent(dim1, dim2) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_cube_roundtrip_metric_properties(): - """Test that metric properties survive Cube roundtrip.""" - adapter = CubeAdapter() - graph1 = adapter.parse("tests/fixtures/cube/orders.yml") - orders1 = graph1.models["orders"] - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - orders2 = graph2.models["orders"] - - # Verify each metric property individually - for m1 in orders1.metrics: - m2 = orders2.get_metric(m1.name) - assert m2 is not None, f"Metric {m1.name} missing after roundtrip" - assert_metric_equivalent(m1, m2) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_cube_roundtrip_segment_properties(): - """Test that segment properties survive Cube roundtrip.""" - adapter = CubeAdapter() - graph1 = adapter.parse("tests/fixtures/cube/orders.yml") - orders1 = graph1.models["orders"] - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - orders2 = graph2.models["orders"] - - # Verify each segment property individually - for seg1 in orders1.segments: - seg2 = orders2.get_segment(seg1.name) - assert seg2 is not None, f"Segment {seg1.name} missing after roundtrip" - assert_segment_equivalent(seg1, seg2) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_cube_to_lookml_conversion(): - """Test converting Cube format to LookML format.""" - # Import from Cube - cube_adapter = CubeAdapter() - graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") - - # Export to LookML - lookml_adapter = LookMLAdapter() - with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: - temp_path = Path(f.name) - - try: - lookml_adapter.export(graph, temp_path) - - # Re-import as LookML and verify structure - graph2 = lookml_adapter.parse(temp_path) - - assert "orders" in graph2.models - orders = graph2.models["orders"] - - # Verify dimensions converted - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - - # Verify measures converted - measure_names = [m.name for m in orders.metrics] - assert "revenue" in measure_names - - # Verify segments converted - if orders.segments: - assert len(orders.segments) > 0 - - finally: - temp_path.unlink(missing_ok=True) - - -def test_cube_to_hex_conversion(): - """Test converting Cube format to Hex format.""" - # Import from Cube - cube_adapter = CubeAdapter() - graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") - - # Export to Hex - hex_adapter = HexAdapter() - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - hex_adapter.export(graph, temp_path) - - # Re-import as Hex and verify structure - graph2 = hex_adapter.parse(temp_path) - - assert "orders" in graph2.models - orders = graph2.models["orders"] - - # Verify dimensions converted - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - - # Verify measures converted - measure_names = [m.name for m in orders.metrics] - assert "revenue" in measure_names - - finally: - temp_path.unlink(missing_ok=True) - - -def test_cube_to_rill_conversion(): - """Test converting Cube format to Rill format.""" - # Import from Cube - cube_adapter = CubeAdapter() - graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") - - # Export to Rill - rill_adapter = RillAdapter() - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - rill_adapter.export(graph, output_path) - - # Re-import as Rill and verify structure - graph2 = rill_adapter.parse(output_path / "orders.yaml") - - assert "orders" in graph2.models - orders = graph2.models["orders"] - - # Verify dimensions converted - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - - # Verify measures converted - measure_names = [m.name for m in orders.metrics] - assert "revenue" in measure_names - - -def test_cube_to_superset_conversion(): - """Test converting Cube schema to Superset dataset.""" - cube_adapter = CubeAdapter() - superset_adapter = SupersetAdapter() - - # Import from Cube - graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") - - # Export to Superset - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - superset_adapter.export(graph, output_path) - - # Import Superset version - superset_graph = superset_adapter.parse(output_path / "orders.yaml") - - # Verify model exists - assert "orders" in superset_graph.models - - -def test_cube_to_omni_conversion(): - """Test converting Cube schema to Omni view.""" - cube_adapter = CubeAdapter() - omni_adapter = OmniAdapter() - - # Import from Cube - graph = cube_adapter.parse("tests/fixtures/cube/orders.yml") - - # Export to Omni - with tempfile.TemporaryDirectory() as tmpdir: - output_path = Path(tmpdir) - omni_adapter.export(graph, output_path) - - # Import Omni version - omni_graph = omni_adapter.parse(output_path) - - # Verify model exists - assert "orders" in omni_graph.models - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_lookml_roundtrip.py b/tests/adapters/test_lookml_roundtrip.py deleted file mode 100644 index a08dc400..00000000 --- a/tests/adapters/test_lookml_roundtrip.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Test import/export/roundtrip for LookML adapter.""" - -import tempfile -from pathlib import Path - -import pytest - -from sidemantic.adapters.cube import CubeAdapter -from sidemantic.adapters.lookml import LookMLAdapter -from sidemantic.adapters.metricflow import MetricFlowAdapter -from tests.adapters.helpers import ( - assert_dimension_equivalent, - assert_graph_equivalent, - assert_metric_equivalent, - assert_segment_equivalent, -) - - -def test_import_real_lookml_example(): - """Test importing a real LookML view file.""" - adapter = LookMLAdapter() - graph = adapter.parse("tests/fixtures/lookml/orders.lkml") - - # Verify models loaded - assert "orders" in graph.models - assert "customers" in graph.models - - orders = graph.models["orders"] - - # Verify dimensions - dim_names = [d.name for d in orders.dimensions] - assert "id" in dim_names - assert "status" in dim_names - assert "customer_id" in dim_names - - # Verify time dimensions were created from dimension_group - assert "created_date" in dim_names - - # Verify primary key was detected - assert orders.primary_key == "id" - - # Verify measures - measure_names = [m.name for m in orders.metrics] - assert "count" in measure_names - assert "revenue" in measure_names - assert "completed_revenue" in measure_names - assert "conversion_rate" in measure_names - - # Verify segments (LookML filters) were imported - segment_names = [s.name for s in orders.segments] - assert "high_value" in segment_names - assert "completed" in segment_names - - # Verify segment SQL was converted from ${TABLE} to {model} - high_value_segment = next(s for s in orders.segments if s.name == "high_value") - assert "{model}" in high_value_segment.sql - assert "${TABLE}" not in high_value_segment.sql - - # Verify measure with filter was imported - completed_revenue = next(m for m in orders.metrics if m.name == "completed_revenue") - assert completed_revenue.filters is not None - assert len(completed_revenue.filters) > 0 - - # Verify derived metric (type=number) was detected - conversion_rate = next(m for m in orders.metrics if m.name == "conversion_rate") - assert conversion_rate.type == "derived" - - -def test_import_lookml_derived_table(): - """Test importing LookML view with derived table.""" - adapter = LookMLAdapter() - graph = adapter.parse("tests/fixtures/lookml/derived_tables.lkml") - - # Verify model loaded - assert "customer_summary" in graph.models - summary = graph.models["customer_summary"] - - # Verify derived table SQL was imported - assert summary.sql is not None - assert "SELECT" in summary.sql.upper() - assert "GROUP BY" in summary.sql.upper() - - # Verify dimensions - dim_names = [d.name for d in summary.dimensions] - assert "customer_id" in dim_names - assert "order_count" in dim_names - assert "total_revenue" in dim_names - - # Verify time dimension_group created time dimensions - assert "last_order_date" in dim_names - - # Verify measures - measure_names = [m.name for m in summary.metrics] - assert "total_customers" in measure_names - assert "avg_orders_per_customer" in measure_names - assert "avg_customer_ltv" in measure_names - - -def test_lookml_to_sidemantic_to_lookml_roundtrip(): - """Test that LookML -> Sidemantic -> LookML preserves structure.""" - # Import from LookML - lookml_adapter = LookMLAdapter() - graph1 = lookml_adapter.parse("tests/fixtures/lookml/orders.lkml") - - # Export back to LookML - with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: - temp_path = Path(f.name) - - try: - lookml_adapter.export(graph1, temp_path) - - # Re-import and verify - graph2 = lookml_adapter.parse(temp_path) - - # Verify semantic equivalence - # NOTE: LookML relationships come from explore files, not view files - assert_graph_equivalent(graph1, graph2, check_relationships=False) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_lookml_to_cube_conversion(): - """Test converting LookML format to Cube format.""" - # Import from LookML - lookml_adapter = LookMLAdapter() - graph = lookml_adapter.parse("tests/fixtures/lookml/orders.lkml") - - # Export to Cube - cube_adapter = CubeAdapter() - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - cube_adapter.export(graph, temp_path) - - # Re-import as Cube and verify structure - graph2 = cube_adapter.parse(temp_path) - - assert "orders" in graph2.models - orders = graph2.models["orders"] - - # Verify dimensions converted - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - - # Verify measures converted - measure_names = [m.name for m in orders.metrics] - assert "revenue" in measure_names - - # Verify segments preserved - segment_names = [s.name for s in orders.segments] - assert "high_value" in segment_names - - finally: - temp_path.unlink(missing_ok=True) - - -def test_lookml_to_metricflow_conversion(): - """Test converting LookML format to MetricFlow format.""" - # Import from LookML - lookml_adapter = LookMLAdapter() - graph = lookml_adapter.parse("tests/fixtures/lookml/orders.lkml") - - # Export to MetricFlow - mf_adapter = MetricFlowAdapter() - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - mf_adapter.export(graph, temp_path) - - # Re-import as MetricFlow and verify structure - graph2 = mf_adapter.parse(temp_path) - - assert "orders" in graph2.models - orders = graph2.models["orders"] - - # Verify dimensions converted - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - - # Verify measures converted - measure_names = [m.name for m in orders.metrics] - assert "revenue" in measure_names - - # Verify segments stored in meta (MetricFlow doesn't have native support) - if orders.segments: - assert len(orders.segments) > 0 - - finally: - temp_path.unlink(missing_ok=True) - - -def test_query_imported_lookml_example(): - """Test that we can compile queries from imported LookML schema.""" - from sidemantic import SemanticLayer - - adapter = LookMLAdapter() - graph = adapter.parse("tests/fixtures/lookml/orders.lkml") - - layer = SemanticLayer() - layer.graph = graph - - # Test basic metric query - sql = layer.compile(metrics=["orders.revenue"]) - assert "SUM" in sql.upper() - - # Test with dimension - sql = layer.compile(metrics=["orders.revenue", "orders.count"], dimensions=["orders.status"]) - assert "GROUP BY" in sql.upper() - assert "status" in sql.lower() - - # Test with segment - sql = layer.compile(metrics=["orders.revenue"], segments=["orders.completed"]) - assert "WHERE" in sql.upper() - assert "status" in sql.lower() - - -def test_lookml_explore_parsing(): - """Test parsing LookML explore files for relationships.""" - adapter = LookMLAdapter() - # Parse just the orders example files (view + explore) to avoid duplicate models - # The explore file defines relationships between models - import shutil - import tempfile - - with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) - - # Copy just the orders files - shutil.copy("tests/fixtures/lookml/orders.lkml", tmpdir_path / "orders.lkml") - shutil.copy("tests/fixtures/lookml/orders.explore.lkml", tmpdir_path / "orders.explore.lkml") - - graph = adapter.parse(tmpdir_path) - - # Verify orders model exists - assert "orders" in graph.models - orders = graph.models["orders"] - - # Verify relationship was parsed from explore - assert len(orders.relationships) >= 1 - - # Verify relationship details - customer_rel = next((r for r in orders.relationships if r.name == "customers"), None) - assert customer_rel is not None - assert customer_rel.type == "many_to_one" - assert customer_rel.foreign_key == "customer_id" - - -def test_query_with_time_dimension_lookml(): - """Test querying time dimensions from LookML import.""" - from sidemantic import SemanticLayer - - adapter = LookMLAdapter() - graph = adapter.parse("tests/fixtures/lookml/orders.lkml") - - layer = SemanticLayer() - layer.graph = graph - - # Query with time dimension - sql = layer.compile(metrics=["orders.revenue"], dimensions=["orders.created_date"]) - assert "created_at" in sql.lower() - assert "GROUP BY" in sql.upper() - - -def test_roundtrip_real_lookml_example(): - """Test LookML example roundtrip using the actual example file.""" - adapter = LookMLAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") - - # Export - with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - - # Import exported version - graph2 = adapter.parse(temp_path) - - # Verify semantic equivalence - assert_graph_equivalent(graph1, graph2, check_relationships=False) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_lookml_roundtrip_dimension_properties(): - """Test that dimension properties survive LookML roundtrip.""" - adapter = LookMLAdapter() - graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") - - with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - - for model_name, model1 in graph1.models.items(): - model2 = graph2.models[model_name] - for dim1 in model1.dimensions: - dim2 = model2.get_dimension(dim1.name) - assert dim2 is not None, f"Dimension {model_name}.{dim1.name} missing after roundtrip" - assert_dimension_equivalent(dim1, dim2) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_lookml_roundtrip_metric_properties(): - """Test that metric properties survive LookML roundtrip.""" - adapter = LookMLAdapter() - graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") - - with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - - for model_name, model1 in graph1.models.items(): - model2 = graph2.models[model_name] - for m1 in model1.metrics: - m2 = model2.get_metric(m1.name) - assert m2 is not None, f"Metric {model_name}.{m1.name} missing after roundtrip" - assert_metric_equivalent(m1, m2) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_lookml_roundtrip_segment_properties(): - """Test that segment properties survive LookML roundtrip.""" - adapter = LookMLAdapter() - graph1 = adapter.parse("tests/fixtures/lookml/orders.lkml") - - with tempfile.NamedTemporaryFile(mode="w", suffix=".lkml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - - for model_name, model1 in graph1.models.items(): - model2 = graph2.models[model_name] - for seg1 in model1.segments: - seg2 = model2.get_segment(seg1.name) - assert seg2 is not None, f"Segment {model_name}.{seg1.name} missing after roundtrip" - assert_segment_equivalent(seg1, seg2) - - finally: - temp_path.unlink(missing_ok=True) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/adapters/test_metricflow_roundtrip.py b/tests/adapters/test_metricflow_roundtrip.py deleted file mode 100644 index a2a1f8bc..00000000 --- a/tests/adapters/test_metricflow_roundtrip.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Test import/export/roundtrip for MetricFlow adapter.""" - -import tempfile -from pathlib import Path - -import pytest - -from sidemantic.adapters.cube import CubeAdapter -from sidemantic.adapters.metricflow import MetricFlowAdapter -from tests.adapters.helpers import ( - assert_dimension_equivalent, - assert_graph_equivalent, - assert_metric_equivalent, -) - - -def test_import_real_metricflow_example(): - """Test importing a real dbt MetricFlow schema file.""" - adapter = MetricFlowAdapter() - graph = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - # Verify models loaded - assert "orders" in graph.models - assert "customers" in graph.models - - orders = graph.models["orders"] - customers = graph.models["customers"] - - # Verify dimensions - order_dims = [d.name for d in orders.dimensions] - assert "order_date" in order_dims - assert "status" in order_dims - - customer_dims = [d.name for d in customers.dimensions] - assert "region" in customer_dims - assert "tier" in customer_dims - - # Verify measures - measure_names = [m.name for m in orders.metrics] - assert "order_count" in measure_names - assert "revenue" in measure_names - assert "avg_order_value" in measure_names - - # Verify relationships were created from entities (resolved to model names) - rel_names = [r.name for r in orders.relationships] - assert "customers" in rel_names - customer_rel = next(r for r in orders.relationships if r.name == "customers") - assert customer_rel.type == "many_to_one" - assert customer_rel.foreign_key == "customer_id" - - # Verify graph-level metrics - assert "total_revenue" in graph.metrics - assert "average_order_value" in graph.metrics - - total_revenue = graph.metrics["total_revenue"] - assert total_revenue.type is None # Simple metric maps to untyped - - avg_order = graph.metrics["average_order_value"] - assert avg_order.type == "ratio" - assert avg_order.numerator == "revenue" - assert avg_order.denominator == "order_count" - - -def test_metricflow_to_sidemantic_to_metricflow_roundtrip(): - """Test that MetricFlow -> Sidemantic -> MetricFlow preserves structure.""" - # Import from MetricFlow - mf_adapter = MetricFlowAdapter() - graph = mf_adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - # Export back to MetricFlow - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - mf_adapter.export(graph, temp_path) - - # Re-import and verify - graph2 = mf_adapter.parse(temp_path) - - # Verify models preserved - assert "orders" in graph2.models - assert "customers" in graph2.models - - # Verify metrics preserved - # Note: Simple metrics that just reference measures may not round-trip - # since they can be queried directly via the measure - assert "average_order_value" in graph2.metrics - - # Verify metric types preserved - avg_order = graph2.metrics["average_order_value"] - assert avg_order.type == "ratio" - - # total_revenue is a simple metric, may not be preserved in export - # (can be queried directly as orders.revenue) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_metricflow_to_cube_conversion(): - """Test converting MetricFlow format to Cube format.""" - # Import from MetricFlow - mf_adapter = MetricFlowAdapter() - graph = mf_adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - # Export to Cube - cube_adapter = CubeAdapter() - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - cube_adapter.export(graph, temp_path) - - # Re-import as Cube and verify structure - graph2 = cube_adapter.parse(temp_path) - - assert "orders" in graph2.models - assert "customers" in graph2.models - - orders = graph2.models["orders"] - - # Verify dimensions converted - dim_names = [d.name for d in orders.dimensions] - assert "status" in dim_names - - # Verify measures converted - measure_names = [m.name for m in orders.metrics] - assert "revenue" in measure_names - - finally: - temp_path.unlink(missing_ok=True) - - -def test_query_imported_metricflow_example(): - """Test that we can compile queries from imported MetricFlow schema.""" - from sidemantic import SemanticLayer - - adapter = MetricFlowAdapter() - graph = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - layer = SemanticLayer() - layer.graph = graph - - # Test basic measure query - sql = layer.compile(metrics=["orders.revenue"]) - assert "SUM" in sql.upper() - - # Test with dimension - sql = layer.compile(metrics=["orders.revenue", "orders.order_count"], dimensions=["orders.status"]) - assert "GROUP BY" in sql.upper() - assert "status" in sql.lower() - - # Test cross-model query (only if join path exists) - # Note: MetricFlow entities may not map 1:1 to model names - try: - sql = layer.compile(metrics=["orders.revenue"], dimensions=["customers.region"]) - assert "JOIN" in sql.upper() - assert "customers" in sql.lower() - except Exception: - # Join path not configured, which is expected for some imports - pass - - # Test graph-level ratio metric (if it exists and is queryable) - if "average_order_value" in graph.metrics: - avg_metric = graph.metrics["average_order_value"] - # Ratio metrics should have numerator/denominator set - if avg_metric.type == "ratio" and avg_metric.numerator and avg_metric.denominator: - try: - sql = layer.compile(metrics=["average_order_value"]) - assert sql # Should generate valid SQL with ratio calculation - except ValueError: - # Some graph-level metrics may need model context to be queryable - pass - - -def test_query_with_filter_metricflow(): - """Test that metric filters work from MetricFlow import.""" - from sidemantic import SemanticLayer - - adapter = MetricFlowAdapter() - graph = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - layer = SemanticLayer() - layer.graph = graph - - # Query with filter - sql = layer.compile(metrics=["orders.revenue"], filters=["orders.status = 'completed'"]) - assert "WHERE" in sql.upper() - assert "status" in sql.lower() - assert "completed" in sql.lower() - - -def test_roundtrip_real_metricflow_example(): - """Test MetricFlow example roundtrip using the actual example file.""" - adapter = MetricFlowAdapter() - - # Import original - graph1 = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - # Export - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - - # Import exported version - graph2 = adapter.parse(temp_path) - - # Verify semantic equivalence - # NOTE: MetricFlow entities create relationships that may not fully round-trip - # NOTE: MetricFlow uses ref() syntax which doesn't preserve schema prefixes - assert_graph_equivalent(graph1, graph2, check_relationships=False, check_table_schema=False) - - # Verify graph-level metrics preserved - # Note: Simple metrics may not round-trip, so we just check that ratio metrics are there - ratio_metrics1 = {name for name, m in graph1.metrics.items() if m.type == "ratio"} - ratio_metrics2 = {name for name, m in graph2.metrics.items() if m.type == "ratio"} - assert ratio_metrics1 == ratio_metrics2 - - # Verify ratio metric properties preserved - for name in ratio_metrics1: - m1 = graph1.metrics[name] - m2 = graph2.metrics[name] - assert m1.numerator == m2.numerator, f"Metric {name}: numerator mismatch" - assert m1.denominator == m2.denominator, f"Metric {name}: denominator mismatch" - - finally: - temp_path.unlink(missing_ok=True) - - -def test_metricflow_roundtrip_dimension_properties(): - """Test that dimension properties survive MetricFlow roundtrip.""" - adapter = MetricFlowAdapter() - graph1 = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - - for model_name, model1 in graph1.models.items(): - model2 = graph2.models[model_name] - for dim1 in model1.dimensions: - dim2 = model2.get_dimension(dim1.name) - assert dim2 is not None, f"Dimension {model_name}.{dim1.name} missing after roundtrip" - assert_dimension_equivalent(dim1, dim2) - - finally: - temp_path.unlink(missing_ok=True) - - -def test_metricflow_roundtrip_metric_properties(): - """Test that metric properties survive MetricFlow roundtrip.""" - adapter = MetricFlowAdapter() - graph1 = adapter.parse("tests/fixtures/metricflow/semantic_models.yml") - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: - temp_path = Path(f.name) - - try: - adapter.export(graph1, temp_path) - graph2 = adapter.parse(temp_path) - - for model_name, model1 in graph1.models.items(): - model2 = graph2.models[model_name] - for m1 in model1.metrics: - m2 = model2.get_metric(m1.name) - assert m2 is not None, f"Metric {model_name}.{m1.name} missing after roundtrip" - assert_metric_equivalent(m1, m2) - - finally: - temp_path.unlink(missing_ok=True) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"])