From bab16098f40ecf9008bddd3250ad04bb2abf3230 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 13 May 2026 19:31:21 -0700 Subject: [PATCH 1/9] Add DAX parser and TMDL import --- .github/workflows/ci.yml | 61 +- .github/workflows/publish.yml | 197 +- .github/workflows/pyodide-test.yml | 8 +- .gitignore | 1 + Cargo.lock | 580 + Cargo.toml | 7 + README.md | 61 +- crates/dax-parser/Cargo.toml | 10 + crates/dax-parser/src/lib.rs | 2352 +++ crates/dax-parser/tests/corpus.rs | 95 + crates/dax-parser/tests/corpus_errors.rs | 40 + crates/dax-parser/tests/corpus_functions.rs | 40 + crates/dax-parser/tests/corpus_queries.rs | 59 + crates/dax-pyo3/Cargo.toml | 19 + crates/dax-pyo3/README.md | 34 + crates/dax-pyo3/pyproject.toml | 17 + .../python/sidemantic_dax/__init__.py | 186 + crates/dax-pyo3/python/sidemantic_dax/ast.py | 776 + .../dax-pyo3/python/sidemantic_dax/py.typed | 0 crates/dax-pyo3/src/lib.rs | 36 + pyproject.toml | 25 +- sidemantic-rs/Cargo.toml | 2 +- sidemantic-rs/src/sql/rewriter.rs | 16 +- sidemantic-schema.json | 6379 ++++---- sidemantic/adapters/sidemantic.py | 112 +- sidemantic/adapters/tmdl.py | 2278 +++ sidemantic/adapters/tmdl_parser.py | 772 + sidemantic/cli.py | 178 +- sidemantic/core/dimension.py | 4 + sidemantic/core/introspection.py | 346 + sidemantic/core/metric.py | 14 +- sidemantic/core/model.py | 4 + sidemantic/core/relationship.py | 14 + sidemantic/core/semantic_graph.py | 104 +- sidemantic/core/semantic_layer.py | 184 +- sidemantic/dax/__init__.py | 33 + sidemantic/dax/modeling.py | 335 + sidemantic/dax/runtime.py | 110 + sidemantic/dax/translator.py | 7367 +++++++++ sidemantic/loaders.py | 133 +- sidemantic/sql/generator.py | 337 +- .../tmdl/test_external_tmdl_fixtures.py | 58 + tests/adapters/tmdl/test_parser.py | 194 + tests/adapters/tmdl/test_parsing.py | 2716 ++++ tests/dax/fixtures/README.md | 31 + tests/dax/fixtures/pbi_parsers/LICENSE | 21 + .../dax/fixtures/pbi_parsers/expressions.txt | 12 + tests/dax/fixtures/pydaxlexer/LICENSE | 21 + tests/dax/fixtures/pydaxlexer/expressions.txt | 520 + tests/dax/fixtures/pydaxlexer/stress.txt | 69 + tests/dax/fixtures/query-docs/LICENSE | 395 + tests/dax/fixtures/query-docs/LICENSE-CODE | 17 + tests/dax/fixtures/query-docs/queries.txt | 87 + tests/dax/fixtures/tabulareditor/LICENSE | 21 + .../tabulareditor/keyword_functions.txt | 23 + tests/dax/fixtures/tabulareditor/keywords.txt | 392 + tests/dax/test_ast.py | 112 + tests/dax/test_external_powerbi_fixtures.py | 59 + tests/dax/test_model_authoring.py | 748 + tests/dax/test_query_corpus.py | 80 + tests/dax/test_query_translation.py | 7081 +++++++++ tests/dax/test_runtime.py | 138 + tests/dax/test_translation.py | 6070 ++++++++ tests/fixtures/external_powerbi/SOURCES.md | 18 + .../marfolger-powerbi-dax/LICENSE.upstream | 21 + .../business_logic_DAX.txt | 49 + .../LICENSE.upstream | 21 + .../definition/cultures/en-US.tmdl | 12772 ++++++++++++++++ .../definition/database.tmdl | 4 + .../definition/expressions.tmdl | 185 + .../definition/model.tmdl | 42 + .../definition/relationships.tmdl | 21 + .../definition/roles/Stores Cluster 1.tmdl | 7 + .../definition/roles/Stores Cluster 2.tmdl | 7 + .../definition/tables/About.tmdl | 48 + .../definition/tables/Calendar.tmdl | 397 + .../definition/tables/Customer.tmdl | 138 + .../definition/tables/Dynamic Measure.tmdl | 164 + .../tables/Parameter - Dimension.tmdl | 55 + .../tables/Parameter - Measure.tmdl | 58 + .../definition/tables/Product.tmdl | 156 + .../definition/tables/Sales.tmdl | 372 + .../definition/tables/Smart Calcs.tmdl | 96 + .../definition/tables/Store.tmdl | 105 + .../definition/tables/Time Intelligence.tmdl | 75 + .../LICENSE.upstream | 21 + .../definition/database.tmdl | 3 + .../definition/expressions.tmdl | 9 + .../definition/model.tmdl | 16 + .../definition/tables/churn.tmdl | 192 + .../LICENSE.upstream | 21 + .../cultures/en-US.tmdl | 4164 +++++ .../database.tmdl | 3 + .../expressions.tmdl | 6 + .../model.tmdl | 22 + .../relationships.tmdl | 36 + .../tables/Customer.tmdl | 77 + .../tables/Date.tmdl | 88 + .../tables/Product.tmdl | 87 + .../tables/Reseller.tmdl | 80 + .../tables/Sales Order.tmdl | 52 + .../tables/Sales Territory.tmdl | 46 + .../tables/Sales.tmdl | 169 + .../LICENSE.upstream | 21 + .../definition/expressions.tmdl | 8 + .../definition/relationships.tmdl | 11 + .../definition/tables/Customers.tmdl | 13 + .../definition/tables/DateTable.tmdl | 16 + .../definition/tables/FieldParameter.tmdl | 21 + .../definition/tables/Products.tmdl | 15 + .../definition/tables/Sales.tmdl | 33 + .../definition/tables/TimeCalc.tmdl | 7 + .../LICENSE.upstream | 21 + .../definition/database.tmdl | 3 + .../definition/expressions.tmdl | 1 + .../definition/model.tmdl | 23 + .../definition/relationships.tmdl | 17 + .../definition/roles/Store - Canada.tmdl | 7 + .../roles/Store - United States.tmdl | 7 + .../definition/tables/Calendar.tmdl | 148 + .../definition/tables/Product.tmdl | 120 + .../definition/tables/Sales.tmdl | 255 + .../definition/tables/Store.tmdl | 90 + tests/fixtures/tmdl/definition/database.tmdl | 2 + tests/fixtures/tmdl/definition/model.tmdl | 5 + .../tmdl/definition/relationships.tmdl | 6 + .../tmdl/definition/tables/Products.tmdl | 9 + .../tmdl/definition/tables/Sales.tmdl | 25 + .../tmdl_realistic/definition/database.tmdl | 6 + .../tmdl_realistic/definition/model.tmdl | 19 + .../definition/relationships.tmdl | 15 + .../definition/tables/Calendar.tmdl | 12 + .../definition/tables/Products.tmdl | 12 + .../definition/tables/Sales By Category.tmdl | 9 + .../definition/tables/Sales.tmdl | 37 + .../tmdl_warning/definition/model.tmdl | 4 + .../definition/relationships.tmdl | 5 + .../definition/tables/Bad Table.tmdl | 4 + .../tmdl_warning/definition/tables/Sales.tmdl | 15 + tests/test_cli_commands.py | 258 + tests/test_core_imports.py | 61 + tests/test_loaders.py | 89 + tests/test_relationship_override_sql.py | 114 + tests/test_schema_generation.py | 13 + tests/test_semantic_graph_errors.py | 99 + tests/test_sidemantic_adapter_metadata.py | 73 + uv.lock | 14 +- 147 files changed, 60608 insertions(+), 3229 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/dax-parser/Cargo.toml create mode 100644 crates/dax-parser/src/lib.rs create mode 100644 crates/dax-parser/tests/corpus.rs create mode 100644 crates/dax-parser/tests/corpus_errors.rs create mode 100644 crates/dax-parser/tests/corpus_functions.rs create mode 100644 crates/dax-parser/tests/corpus_queries.rs create mode 100644 crates/dax-pyo3/Cargo.toml create mode 100644 crates/dax-pyo3/README.md create mode 100644 crates/dax-pyo3/pyproject.toml create mode 100644 crates/dax-pyo3/python/sidemantic_dax/__init__.py create mode 100644 crates/dax-pyo3/python/sidemantic_dax/ast.py create mode 100644 crates/dax-pyo3/python/sidemantic_dax/py.typed create mode 100644 crates/dax-pyo3/src/lib.rs create mode 100644 sidemantic/adapters/tmdl.py create mode 100644 sidemantic/adapters/tmdl_parser.py create mode 100644 sidemantic/core/introspection.py create mode 100644 sidemantic/dax/__init__.py create mode 100644 sidemantic/dax/modeling.py create mode 100644 sidemantic/dax/runtime.py create mode 100644 sidemantic/dax/translator.py create mode 100644 tests/adapters/tmdl/test_external_tmdl_fixtures.py create mode 100644 tests/adapters/tmdl/test_parser.py create mode 100644 tests/adapters/tmdl/test_parsing.py create mode 100644 tests/dax/fixtures/README.md create mode 100644 tests/dax/fixtures/pbi_parsers/LICENSE create mode 100644 tests/dax/fixtures/pbi_parsers/expressions.txt create mode 100644 tests/dax/fixtures/pydaxlexer/LICENSE create mode 100644 tests/dax/fixtures/pydaxlexer/expressions.txt create mode 100644 tests/dax/fixtures/pydaxlexer/stress.txt create mode 100644 tests/dax/fixtures/query-docs/LICENSE create mode 100644 tests/dax/fixtures/query-docs/LICENSE-CODE create mode 100644 tests/dax/fixtures/query-docs/queries.txt create mode 100644 tests/dax/fixtures/tabulareditor/LICENSE create mode 100644 tests/dax/fixtures/tabulareditor/keyword_functions.txt create mode 100644 tests/dax/fixtures/tabulareditor/keywords.txt create mode 100644 tests/dax/test_ast.py create mode 100644 tests/dax/test_external_powerbi_fixtures.py create mode 100644 tests/dax/test_model_authoring.py create mode 100644 tests/dax/test_query_corpus.py create mode 100644 tests/dax/test_query_translation.py create mode 100644 tests/dax/test_runtime.py create mode 100644 tests/dax/test_translation.py create mode 100644 tests/fixtures/external_powerbi/SOURCES.md create mode 100644 tests/fixtures/external_powerbi/marfolger-powerbi-dax/LICENSE.upstream create mode 100644 tests/fixtures/external_powerbi/marfolger-powerbi-dax/business_logic_DAX.txt create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/LICENSE.upstream create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/cultures/en-US.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/database.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/expressions.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/model.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/relationships.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 1.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 2.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/About.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Calendar.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Customer.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Dynamic Measure.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Dimension.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Measure.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Product.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Sales.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Smart Calcs.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Store.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Time Intelligence.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/LICENSE.upstream create mode 100644 tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/database.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/expressions.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/model.tmdl create mode 100644 tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/tables/churn.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/LICENSE.upstream create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/cultures/en-US.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/database.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/expressions.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/model.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/relationships.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Customer.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Date.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Product.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Reseller.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Order.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Territory.tmdl create mode 100644 tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/LICENSE.upstream create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/expressions.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/relationships.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Customers.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/DateTable.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/FieldParameter.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Products.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Sales.tmdl create mode 100644 tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/TimeCalc.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/LICENSE.upstream create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/database.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/expressions.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/model.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/relationships.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - Canada.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - United States.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Calendar.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Product.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Sales.tmdl create mode 100644 tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Store.tmdl create mode 100644 tests/fixtures/tmdl/definition/database.tmdl create mode 100644 tests/fixtures/tmdl/definition/model.tmdl create mode 100644 tests/fixtures/tmdl/definition/relationships.tmdl create mode 100644 tests/fixtures/tmdl/definition/tables/Products.tmdl create mode 100644 tests/fixtures/tmdl/definition/tables/Sales.tmdl create mode 100644 tests/fixtures/tmdl_realistic/definition/database.tmdl create mode 100644 tests/fixtures/tmdl_realistic/definition/model.tmdl create mode 100644 tests/fixtures/tmdl_realistic/definition/relationships.tmdl create mode 100644 tests/fixtures/tmdl_realistic/definition/tables/Calendar.tmdl create mode 100644 tests/fixtures/tmdl_realistic/definition/tables/Products.tmdl create mode 100644 tests/fixtures/tmdl_realistic/definition/tables/Sales By Category.tmdl create mode 100644 tests/fixtures/tmdl_realistic/definition/tables/Sales.tmdl create mode 100644 tests/fixtures/tmdl_warning/definition/model.tmdl create mode 100644 tests/fixtures/tmdl_warning/definition/relationships.tmdl create mode 100644 tests/fixtures/tmdl_warning/definition/tables/Bad Table.tmdl create mode 100644 tests/fixtures/tmdl_warning/definition/tables/Sales.tmdl create mode 100644 tests/test_core_imports.py create mode 100644 tests/test_relationship_override_sql.py create mode 100644 tests/test_sidemantic_adapter_metadata.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91643664..71daa213 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -24,9 +27,25 @@ jobs: - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} - - name: Install dependencies + - name: Install core dependencies run: uv sync --extra dev + - name: Check core import without DAX extra + run: | + uv run python - <<'PY' + import sys + + from sidemantic import Dimension, Metric, Model + + assert Model.__name__ == "Model" + assert Dimension.__name__ == "Dimension" + assert Metric.__name__ == "Metric" + assert "sidemantic_dax" not in sys.modules + PY + + - name: Install DAX dependencies + run: uv sync --extra dev --extra dax + - name: Check version consistency run: | PYPROJECT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') @@ -46,6 +65,20 @@ jobs: - name: Run tests run: uv run pytest -v + - name: Run installed-wheel DAX smoke + run: | + uv build crates/dax-pyo3 --out-dir /tmp/sidemantic-dax-dist + uv build --out-dir /tmp/sidemantic-dist + SIDEMANTIC_WHEEL=$(realpath "$(find /tmp/sidemantic-dist -name 'sidemantic-[0-9]*.whl' -print -quit)") + cd /tmp + uv run --no-project --find-links /tmp/sidemantic-dax-dist --with "${SIDEMANTIC_WHEEL}[dax]" -- python - <<'PY' + from sidemantic import SemanticLayer + import sidemantic_dax + + sidemantic_dax.parse_expression("1") + assert SemanticLayer().compile_dax_query('EVALUATE ROW("one", 1)') == "SELECT 1 AS one" + PY + update-schema: name: Update JSON Schema needs: python @@ -58,6 +91,9 @@ jobs: with: ref: ${{ github.head_ref }} + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -67,7 +103,7 @@ jobs: run: uv python install 3.12 - name: Install dependencies - run: uv sync --extra dev + run: uv sync --extra dev --extra dax - name: Generate and commit schema if needed run: | @@ -93,19 +129,22 @@ jobs: with: filters: | rust: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' - 'sidemantic-rs/**' + - 'tests/dax/fixtures/**' duckdb: - 'sidemantic-duckdb/**' - 'sidemantic-rs/**' + - 'Cargo.toml' + - 'Cargo.lock' rust: - name: Rust (sidemantic-rs) + name: Rust needs: check-rust-changes if: needs.check-rust-changes.outputs.rust_changed == 'true' runs-on: ubuntu-latest - defaults: - run: - working-directory: sidemantic-rs steps: - uses: actions/checkout@v4 @@ -116,16 +155,16 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@v2 with: - workspaces: sidemantic-rs + workspaces: . - name: Run cargo fmt check run: cargo fmt --check - name: Run cargo clippy - run: cargo clippy -- -D warnings + run: cargo clippy -p sidemantic -p dax-parser -p dax-pyo3 --all-targets -- -D warnings - name: Run cargo test - run: cargo test + run: cargo test -p sidemantic -p dax-parser -p dax-pyo3 env: RUST_MIN_STACK: 16777216 @@ -155,7 +194,7 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@v2 with: - workspaces: sidemantic-rs + workspaces: . - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y ninja-build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c92761b5..63d69d81 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,22 +13,13 @@ on: - major jobs: - publish: + version: runs-on: ubuntu-latest - permissions: - id-token: write - contents: write - + outputs: + current_version: ${{ steps.get_version.outputs.current_version }} + new_version: ${{ steps.version.outputs.new_version }} steps: - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install uv - uses: astral-sh/setup-uv@v5 - - - name: Set up Python - run: uv python install 3.12 - name: Get current version id: get_version @@ -59,39 +50,191 @@ jobs: NEW_VERSION="$MAJOR.$MINOR.$PATCH" echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "VERSION=$NEW_VERSION" >> $GITHUB_ENV + + build-dax-wheels: + needs: version + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-13, macos-14, windows-latest] + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Update DAX package version + shell: bash + env: + NEW_VERSION: ${{ needs.version.outputs.new_version }} + run: | + uv run --no-project python - <<'PY' + import os + from pathlib import Path + + version = os.environ["NEW_VERSION"] + + for path in ( + Path("crates/dax-pyo3/pyproject.toml"), + Path("crates/dax-pyo3/Cargo.toml"), + ): + lines = path.read_text().splitlines() + path.write_text( + "\n".join( + f'version = "{version}"' if line.startswith("version = ") else line + for line in lines + ) + + "\n" + ) + PY + + - name: Build DAX wheel + run: uv build crates/dax-pyo3 --wheel --out-dir dist + + - name: Smoke test DAX wheel + shell: bash + run: | + DAX_WHEEL=$(find dist -name 'sidemantic_dax-*.whl' -print -quit) + uv run --no-project --with "$DAX_WHEEL" -- python - <<'PY' + import sidemantic_dax + + sidemantic_dax.parse_expression("1") + PY + + - name: Upload DAX wheel + uses: actions/upload-artifact@v4 + with: + name: dax-wheel-${{ matrix.os }} + path: dist/*.whl + + publish: + needs: [version, build-dax-wheels] + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable - name: Update version in pyproject.toml and __init__.py + shell: bash + env: + NEW_VERSION: ${{ needs.version.outputs.new_version }} run: | - sed -i 's/^version = .*/version = "${{ steps.version.outputs.new_version }}"/' pyproject.toml - sed -i 's/^__version__ = .*/__version__ = "${{ steps.version.outputs.new_version }}"/' sidemantic/__init__.py - echo "Updated version to ${{ steps.version.outputs.new_version }}" + uv run --no-project python - <<'PY' + import os + from pathlib import Path + + version = os.environ["NEW_VERSION"] + + version_files = ( + Path("pyproject.toml"), + Path("crates/dax-pyo3/pyproject.toml"), + Path("crates/dax-pyo3/Cargo.toml"), + ) + for path in version_files: + lines = path.read_text().splitlines() + path.write_text( + "\n".join( + f'version = "{version}"' if line.startswith("version = ") else line + for line in lines + ) + + "\n" + ) + + init_path = Path("sidemantic/__init__.py") + init_lines = init_path.read_text().splitlines() + init_path.write_text( + "\n".join( + f'__version__ = "{version}"' if line.startswith("__version__ = ") else line + for line in init_lines + ) + + "\n" + ) + + pyproject_path = Path("pyproject.toml") + pyproject_lines = pyproject_path.read_text().splitlines() + pyproject_path.write_text( + "\n".join( + f' "sidemantic-dax>={version}",' if line.strip().startswith('"sidemantic-dax>=') + else line + for line in pyproject_lines + ) + + "\n" + ) + PY + echo "Updated version to $NEW_VERSION" - name: Update lock file - run: uv lock + run: | + cargo generate-lockfile + uv lock + + - name: Download DAX wheels + uses: actions/download-artifact@v4 + with: + pattern: dax-wheel-* + path: dist + merge-multiple: true + + - name: Build packages + run: | + uv build crates/dax-pyo3 --sdist --out-dir dist + uv build --out-dir dist + + - name: Run installed-wheel DAX smoke + run: | + DIST_DIR=$(realpath dist) + SIDEMANTIC_WHEEL=$(realpath "$(find dist -name 'sidemantic-[0-9]*.whl' -print -quit)") + cd /tmp + uv run --no-project --find-links "${DIST_DIR}" --with "${SIDEMANTIC_WHEEL}[dax]" -- python - <<'PY' + from sidemantic import SemanticLayer + import sidemantic_dax - - name: Build package - run: uv build + sidemantic_dax.parse_expression("1") + assert SemanticLayer().compile_dax_query('EVALUATE ROW("one", 1)') == "SELECT 1 AS one" + PY - name: Publish to PyPI env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: uv publish --token $UV_PUBLISH_TOKEN + run: uv publish dist/* --token $UV_PUBLISH_TOKEN - name: Commit version bump and create tag run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add pyproject.toml sidemantic/__init__.py uv.lock - git commit -m "Bump version to ${{ steps.version.outputs.new_version }}" - git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}" + git add pyproject.toml sidemantic/__init__.py crates/dax-pyo3/pyproject.toml crates/dax-pyo3/Cargo.toml Cargo.lock uv.lock + git commit -m "Bump version to ${{ needs.version.outputs.new_version }}" + git tag -a "v${{ needs.version.outputs.new_version }}" -m "Release v${{ needs.version.outputs.new_version }}" git push origin main - git push origin "v${{ steps.version.outputs.new_version }}" + git push origin "v${{ needs.version.outputs.new_version }}" - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "v${{ steps.version.outputs.new_version }}" \ - --title "v${{ steps.version.outputs.new_version }}" \ + gh release create "v${{ needs.version.outputs.new_version }}" \ + --title "v${{ needs.version.outputs.new_version }}" \ --generate-notes diff --git a/.github/workflows/pyodide-test.yml b/.github/workflows/pyodide-test.yml index f76725b8..b68c63bd 100644 --- a/.github/workflows/pyodide-test.yml +++ b/.github/workflows/pyodide-test.yml @@ -62,8 +62,9 @@ jobs: console.log('Testing basic imports...'); await pyodide.runPythonAsync(` - from sidemantic import Model, Dimension, Metric, Relationship + from sidemantic import Model, Dimension, Metric, Relationship, SemanticLayer from sidemantic.core.semantic_graph import SemanticGraph + from sidemantic.loaders import load_from_directory print('✓ Core imports successful') # Test creating a model (doesn't need duckdb) @@ -88,6 +89,11 @@ jobs: graph.add_model(model) print('✓ SemanticGraph successful') + layer = SemanticLayer() + layer.graph.add_model(model) + assert callable(load_from_directory) + print('✓ SemanticLayer and loader imports successful') + print('All Pyodide imports and basic operations work!') `); } diff --git a/.gitignore b/.gitignore index 7ff678dd..9496934e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ examples/juv.tmp*.py # Package installers *.pkg .claude/settings.local.json +crates/dax-pyo3/python/sidemantic_dax/_native*.so # Docker volumes docker-compose.override.yml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..13fd45a3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,580 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "dax-parser" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "dax-pyo3" +version = "0.1.0" +dependencies = [ + "dax-parser", + "pyo3", + "pythonize", + "serde", + "serde_json", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "polyglot-sql" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a830a5ca14d5197428759c2f72d3de2e824f2e4ca4d4d81b47af37e6c0ca4d63" +dependencies = [ + "serde", + "serde_json", + "stacker", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pyo3" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "pythonize" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0664248812c38cc55a4ed07f88e4df516ce82604b93b1ffdc041aa77a6cb3c" +dependencies = [ + "pyo3", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sidemantic" +version = "0.1.0" +dependencies = [ + "lazy_static", + "nom", + "once_cell", + "polyglot-sql", + "regex", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..89597aa8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = [ + "sidemantic-rs", + "crates/dax-parser", + "crates/dax-pyo3", +] +resolver = "2" diff --git a/README.md b/README.md index b6484e30..a8e96f8e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The universal metrics layer for consistent metrics across your data stack. Compatible with 15+ semantic model formats. -- **Supported Formats:** Sidemantic (YAML, Python or SQL), Cube, dbt MetricFlow, LookML, Hex, Rill, Superset, Omni, BSL, GoodData LDM, Snowflake Cortex, Malloy, OSI, AtScale SML, ThoughtSpot TML +- **Supported Formats:** Sidemantic (YAML, Python or SQL), Power BI TMDL, Cube, dbt MetricFlow, LookML, Hex, Rill, Superset, Omni, BSL, GoodData LDM, Snowflake Cortex, Malloy, OSI, AtScale SML, ThoughtSpot TML - **Databases:** DuckDB, MotherDuck, PostgreSQL, BigQuery, Snowflake, ClickHouse, Databricks, Spark SQL (also via ADBC) [Documentation](https://sidemantic.com) | [GitHub](https://github.com/sidequery/sidemantic) | [Docker Hub](https://hub.docker.com/repository/docker/sidequery/sidemantic) | [Discord](https://discord.com/invite/7MZ4UgSVvF) | [Demo](https://sidemantic.com/demo) (50+ MB data download, runs in your browser with Pyodide + DuckDB) @@ -23,6 +23,11 @@ Malloy support (uv): uv add "sidemantic[malloy]" ``` +DAX and Power BI TMDL support (uv): +```bash +uv add "sidemantic[dax]" +``` + HTTP API server (uv): ```bash uv add "sidemantic[api]" @@ -122,6 +127,55 @@ load_from_directory(layer, "models/") result = layer.sql("SELECT revenue, status FROM orders") ``` +## DAX And TMDL + +DAX/TMDL support lives behind the `dax` extra because it includes a native Rust parser: + +```bash +uv add "sidemantic[dax]" +``` + +Native Sidemantic YAML can define DAX expressions directly. Supported DAX is lowered to executable Sidemantic SQL semantics at load time, while the original DAX is preserved for export and UI metadata: + +```yaml +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: doubled_amount + type: numeric + dax: "'sales'[amount] * 2" + metrics: + - name: revenue + dax: "SUM('sales'[amount])" +``` + +Power BI TMDL projects can be loaded from a project root or `definition/` folder. Embedded DAX measures, calculated columns, calculated tables, relationships, and TMDL passthrough metadata are imported and exposed through model metadata: + +```python +from sidemantic import SemanticLayer, load_from_directory + +layer = SemanticLayer(connection="duckdb:///warehouse.duckdb") +load_from_directory(layer, "powerbi_project/") +print(layer.describe_models(["Sales"])) +``` + +TMDL can also round-trip back to disk: + +```python +from sidemantic.adapters.tmdl import TMDLAdapter + +TMDLAdapter().export(layer.graph, "exported_tmdl/") +``` + +Run DAX `EVALUATE` queries through the CLI: + +```bash +sidemantic dax-query "EVALUATE SUMMARIZECOLUMNS('sales'[category], \"Revenue\", [revenue])" --models models/ --db data.duckdb +sidemantic dax-query "EVALUATE VALUES('sales'[category])" --models models/ --dry-run +``` + ## CLI ```bash @@ -143,6 +197,9 @@ sidemantic validate models/ # Model info sidemantic info models/ +# DAX query +sidemantic dax-query "EVALUATE VALUES('orders'[status])" --models models/ --db data.duckdb + # Pre-aggregation recommendations sidemantic preagg recommend --db data.duckdb @@ -236,7 +293,7 @@ See `examples/` for more. ## Multi-Format Support -Auto-detects: Sidemantic (SQL/YAML), Cube, MetricFlow (dbt), LookML, Hex, Rill, Superset, Omni, BSL, GoodData LDM, OSI, AtScale SML, ThoughtSpot TML +Auto-detects: Sidemantic (SQL/YAML), Power BI TMDL, Cube, MetricFlow (dbt), LookML, Hex, Rill, Superset, Omni, BSL, GoodData LDM, OSI, AtScale SML, ThoughtSpot TML ```bash sidemantic query "SELECT revenue FROM orders" --models ./my_models diff --git a/crates/dax-parser/Cargo.toml b/crates/dax-parser/Cargo.toml new file mode 100644 index 00000000..beec363d --- /dev/null +++ b/crates/dax-parser/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "dax-parser" +version = "0.1.0" +edition = "2021" +description = "DAX lexer+parser" +license = "AGPL-3.0-only" +repository = "https://github.com/sidequery/sidemantic" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } diff --git a/crates/dax-parser/src/lib.rs b/crates/dax-parser/src/lib.rs new file mode 100644 index 00000000..a367f5cf --- /dev/null +++ b/crates/dax-parser/src/lib.rs @@ -0,0 +1,2352 @@ +//! dax_parser.rs — single-file DAX lexer+parser (expressions + basic queries) +//! +//! Patch highlights vs prior version: +//! - Fixes operator precedence per MS docs: `^` binds tighter than unary sign, comparisons bind tighter than `NOT` +//! - Adds `==` strict equality token/op +//! - Supports numeric literals starting with `.` (e.g. `.20`) +//! - Adds `@param` tokens/AST (needed for START AT params) +//! - Enforces START AT rules: requires ORDER BY, args must be constant or @param, count <= order keys +//! - Adds DEFINE FUNCTION (UDF) parsing: `FUNCTION f = (a : type ...) => body` + `///` doc comments +//! - Accepts optional semicolon statement terminators (between DEFINE entities / EVALUATE statements) +//! +//! Drop into `src/lib.rs` (or any module) and `cargo test`. +//! No external deps. + +use serde::Serialize; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Span { + pub start: usize, + pub end: usize, // half-open [start, end) +} +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LexError { + pub message: String, + pub span: Span, +} +impl fmt::Display for LexError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} at {}..{}", + self.message, self.span.start, self.span.end + ) + } +} +impl std::error::Error for LexError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ParseError { + pub message: String, + pub span: Span, +} +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} at {}..{}", + self.message, self.span.start, self.span.end + ) + } +} +impl std::error::Error for ParseError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum DaxError { + Lex(LexError), + Parse(ParseError), +} +impl fmt::Display for DaxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DaxError::Lex(e) => write!(f, "lex error: {e}"), + DaxError::Parse(e) => write!(f, "parse error: {e}"), + } + } +} +impl std::error::Error for DaxError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Dialect { + /// Accept `;` as argument separator (in addition to `,`) + pub allow_semicolon_separators: bool, + /// Accept `,` as decimal separator inside numeric literals (in addition to `.`) + pub allow_decimal_comma: bool, + /// Accept `--` as a line comment starter + pub allow_dash_dash_comments: bool, + /// Accept `//` as a line comment starter + pub allow_double_slash_comments: bool, + /// Accept `/* ... */` block comments + pub allow_block_comments: bool, +} +impl Default for Dialect { + fn default() -> Self { + Self { + allow_semicolon_separators: true, + allow_decimal_comma: false, // canonical DAX is `.` decimal; flip if you want locale-tolerant + allow_dash_dash_comments: true, + allow_double_slash_comments: true, + allow_block_comments: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Token { + pub kind: TokenKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum TokenKind { + // trivia-ish + DocComment(String), // `/// ...` (kept so DEFINE FUNCTION can attach doc) + + // atoms + Ident(String), // includes keywords (contextual) + Param(String), // @paramName (START AT) + Number(String), // raw numeric literal text + String(String), // decoded double-quoted literal + QuotedIdent(String), // decoded single-quoted identifier (e.g. 'Sales') + BracketIdent(String), // decoded bracket identifier (e.g. [Total Sales]) + + // punctuation + LParen, + RParen, + LBrace, + RBrace, + Comma, + Semicolon, + Dot, + Colon, + + // operators / punct + Arrow, // => + + Plus, + Minus, + Star, + Slash, + Caret, + Amp, // concatenation (&) + + Eq, + EqEq, // == + Neq, // <> + Lt, + Lte, + Gt, + Gte, + + AndAnd, // && + OrOr, // || + + Eof, +} + +pub struct Lexer<'a> { + input: &'a str, + idx: usize, + dialect: Dialect, +} +impl<'a> Lexer<'a> { + pub fn new(input: &'a str, dialect: Dialect) -> Self { + Self { + input, + idx: 0, + dialect, + } + } + + pub fn lex_all(mut self) -> Result, LexError> { + let mut out = Vec::new(); + loop { + let tok = self.next_token()?; + let is_eof = matches!(tok.kind, TokenKind::Eof); + out.push(tok); + if is_eof { + break; + } + } + Ok(out) + } + + fn len(&self) -> usize { + self.input.len() + } + + fn peek_char(&self) -> Option { + self.input[self.idx..].chars().next() + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.idx).copied() + } + + fn peek_byte_n(&self, n: usize) -> Option { + self.input.as_bytes().get(self.idx + n).copied() + } + + fn bump_char(&mut self) -> Option { + let ch = self.peek_char()?; + self.idx += ch.len_utf8(); + Some(ch) + } + + fn skip_whitespace(&mut self) { + while let Some(ch) = self.peek_char() { + if ch.is_whitespace() { + self.bump_char(); + } else { + break; + } + } + } + + fn skip_line_comment(&mut self) { + while let Some(ch) = self.peek_char() { + self.bump_char(); + if ch == '\n' { + break; + } + } + } + + fn skip_block_comment(&mut self) -> Result<(), LexError> { + // assumes current is '/' and next is '*' + let start = self.idx; + self.bump_char(); // / + self.bump_char(); // * + while self.idx < self.len() { + if self.peek_byte() == Some(b'*') && self.peek_byte_n(1) == Some(b'/') { + self.bump_char(); // * + self.bump_char(); // / + return Ok(()); + } + self.bump_char(); + } + Err(LexError { + message: "unterminated block comment".into(), + span: Span::new(start, self.idx), + }) + } + + fn lex_doc_comment(&mut self) -> (String, usize) { + // assumes current bytes are "///" + debug_assert_eq!(self.peek_byte(), Some(b'/')); + self.bump_char(); + self.bump_char(); + self.bump_char(); + + let mut out = String::new(); + while let Some(ch) = self.peek_char() { + if ch == '\n' { + break; + } + self.bump_char(); + out.push(ch); + } + + (out.trim().to_string(), self.idx) + } + + fn lex_param(&mut self) -> Result<(String, usize), LexError> { + // @paramName - we accept [A-Za-z0-9_]+ after '@' to be permissive. + let start = self.idx; + debug_assert_eq!(self.peek_char(), Some('@')); + self.bump_char(); // @ + + let mut out = String::new(); + while let Some(ch) = self.peek_char() { + if ch.is_alphanumeric() || ch == '_' { + self.bump_char(); + out.push(ch); + } else { + break; + } + } + + if out.is_empty() { + return Err(LexError { + message: "expected parameter name after '@'".into(), + span: Span::new(start, self.idx), + }); + } + + Ok((out, self.idx)) + } + + fn next_token(&mut self) -> Result { + loop { + self.skip_whitespace(); + + let start = self.idx; + if start >= self.len() { + return Ok(Token { + kind: TokenKind::Eof, + span: Span::new(start, start), + }); + } + + // doc comment: /// ... + if self.dialect.allow_double_slash_comments + && self.peek_byte() == Some(b'/') + && self.peek_byte_n(1) == Some(b'/') + && self.peek_byte_n(2) == Some(b'/') + { + let (text, end) = self.lex_doc_comment(); + return Ok(Token { + kind: TokenKind::DocComment(text), + span: Span::new(start, end), + }); + } + + // comments (ASCII-only starters, but idx is always at char boundary) + match (self.peek_byte(), self.peek_byte_n(1)) { + (Some(b'-'), Some(b'-')) if self.dialect.allow_dash_dash_comments => { + self.skip_line_comment(); + continue; + } + (Some(b'/'), Some(b'/')) if self.dialect.allow_double_slash_comments => { + self.skip_line_comment(); + continue; + } + (Some(b'/'), Some(b'*')) if self.dialect.allow_block_comments => { + self.skip_block_comment()?; + continue; + } + _ => {} + } + + // punctuation/operators (prefer 2-char where relevant) + match (self.peek_byte(), self.peek_byte_n(1)) { + (Some(b'='), Some(b'=')) => { + self.bump_char(); + self.bump_char(); + return Ok(Token { + kind: TokenKind::EqEq, + span: Span::new(start, self.idx), + }); + } + (Some(b'='), Some(b'>')) => { + self.bump_char(); + self.bump_char(); + return Ok(Token { + kind: TokenKind::Arrow, + span: Span::new(start, self.idx), + }); + } + (Some(b'&'), Some(b'&')) => { + self.bump_char(); + self.bump_char(); + return Ok(Token { + kind: TokenKind::AndAnd, + span: Span::new(start, self.idx), + }); + } + (Some(b'|'), Some(b'|')) => { + self.bump_char(); + self.bump_char(); + return Ok(Token { + kind: TokenKind::OrOr, + span: Span::new(start, self.idx), + }); + } + (Some(b'<'), Some(b'>')) => { + self.bump_char(); + self.bump_char(); + return Ok(Token { + kind: TokenKind::Neq, + span: Span::new(start, self.idx), + }); + } + (Some(b'<'), Some(b'=')) => { + self.bump_char(); + self.bump_char(); + return Ok(Token { + kind: TokenKind::Lte, + span: Span::new(start, self.idx), + }); + } + (Some(b'>'), Some(b'=')) => { + self.bump_char(); + self.bump_char(); + return Ok(Token { + kind: TokenKind::Gte, + span: Span::new(start, self.idx), + }); + } + _ => {} + } + + // single-char tokens + let ch = self.peek_char().unwrap(); + match ch { + '(' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::LParen, + span: Span::new(start, self.idx), + }); + } + ')' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::RParen, + span: Span::new(start, self.idx), + }); + } + '{' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::LBrace, + span: Span::new(start, self.idx), + }); + } + '}' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::RBrace, + span: Span::new(start, self.idx), + }); + } + ',' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Comma, + span: Span::new(start, self.idx), + }); + } + ';' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Semicolon, + span: Span::new(start, self.idx), + }); + } + ':' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Colon, + span: Span::new(start, self.idx), + }); + } + '@' => { + let (name, end) = self.lex_param()?; + return Ok(Token { + kind: TokenKind::Param(name), + span: Span::new(start, end), + }); + } + '+' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Plus, + span: Span::new(start, self.idx), + }); + } + '-' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Minus, + span: Span::new(start, self.idx), + }); + } + '*' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Star, + span: Span::new(start, self.idx), + }); + } + '/' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Slash, + span: Span::new(start, self.idx), + }); + } + '^' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Caret, + span: Span::new(start, self.idx), + }); + } + '&' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Amp, + span: Span::new(start, self.idx), + }); + } + '=' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Eq, + span: Span::new(start, self.idx), + }); + } + '<' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Lt, + span: Span::new(start, self.idx), + }); + } + '>' => { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Gt, + span: Span::new(start, self.idx), + }); + } + '"' => { + let (s, end) = self.lex_string_literal()?; + return Ok(Token { + kind: TokenKind::String(s), + span: Span::new(start, end), + }); + } + '\'' => { + let (s, end) = self.lex_single_quoted_ident()?; + return Ok(Token { + kind: TokenKind::QuotedIdent(s), + span: Span::new(start, end), + }); + } + '[' => { + let (s, end) = self.lex_bracket_ident()?; + return Ok(Token { + kind: TokenKind::BracketIdent(s), + span: Span::new(start, end), + }); + } + _ => {} + } + + // number? + if ch.is_ascii_digit() + || (ch == '.' && matches!(self.peek_byte_n(1), Some(b'0'..=b'9'))) + { + let end = self.lex_number()?; + let raw = self.input[start..end].to_string(); + return Ok(Token { + kind: TokenKind::Number(raw), + span: Span::new(start, end), + }); + } + + if ch == '.' { + self.bump_char(); + return Ok(Token { + kind: TokenKind::Dot, + span: Span::new(start, self.idx), + }); + } + + // identifier? + if is_ident_start(ch) { + let end = self.lex_ident()?; + let raw = self.input[start..end].to_string(); + return Ok(Token { + kind: TokenKind::Ident(raw), + span: Span::new(start, end), + }); + } + + // anything else: unknown + let bad = ch; + self.bump_char(); + return Err(LexError { + message: format!("unexpected character: {bad:?}"), + span: Span::new(start, self.idx), + }); + } + } + + fn lex_string_literal(&mut self) -> Result<(String, usize), LexError> { + // DAX string literal: "..." with escape by doubling quotes: "" + let start = self.idx; + debug_assert_eq!(self.peek_char(), Some('"')); + self.bump_char(); // opening " + let mut out = String::new(); + + while self.idx < self.len() { + if self.peek_byte() == Some(b'"') { + if self.peek_byte_n(1) == Some(b'"') { + // escaped quote + self.bump_char(); + self.bump_char(); + out.push('"'); + continue; + } else { + // closing quote + self.bump_char(); + return Ok((out, self.idx)); + } + } + + let ch = self.bump_char().ok_or_else(|| LexError { + message: "unterminated string literal".into(), + span: Span::new(start, self.idx), + })?; + out.push(ch); + } + + Err(LexError { + message: "unterminated string literal".into(), + span: Span::new(start, self.idx), + }) + } + + fn lex_single_quoted_ident(&mut self) -> Result<(String, usize), LexError> { + // DAX quoted identifier for tables: 'Sales' with escape by doubling single quotes: '' + let start = self.idx; + debug_assert_eq!(self.peek_char(), Some('\'')); + self.bump_char(); // opening ' + let mut out = String::new(); + + while self.idx < self.len() { + if self.peek_byte() == Some(b'\'') { + if self.peek_byte_n(1) == Some(b'\'') { + self.bump_char(); + self.bump_char(); + out.push('\''); + continue; + } else { + self.bump_char(); // closing + return Ok((out, self.idx)); + } + } + + let ch = self.bump_char().ok_or_else(|| LexError { + message: "unterminated quoted identifier".into(), + span: Span::new(start, self.idx), + })?; + out.push(ch); + } + + Err(LexError { + message: "unterminated quoted identifier".into(), + span: Span::new(start, self.idx), + }) + } + + fn lex_bracket_ident(&mut self) -> Result<(String, usize), LexError> { + // DAX bracket identifier: [Total Sales] with escape by doubling closing bracket: ]] + let start = self.idx; + debug_assert_eq!(self.peek_char(), Some('[')); + self.bump_char(); // opening [ + let mut out = String::new(); + + while self.idx < self.len() { + if self.peek_byte() == Some(b']') { + if self.peek_byte_n(1) == Some(b']') { + self.bump_char(); + self.bump_char(); + out.push(']'); + continue; + } else { + self.bump_char(); // closing + return Ok((out, self.idx)); + } + } + + let ch = self.bump_char().ok_or_else(|| LexError { + message: "unterminated bracket identifier".into(), + span: Span::new(start, self.idx), + })?; + out.push(ch); + } + + Err(LexError { + message: "unterminated bracket identifier".into(), + span: Span::new(start, self.idx), + }) + } + + fn lex_ident(&mut self) -> Result { + while let Some(ch) = self.peek_char() { + if is_ident_continue(ch) { + self.bump_char(); + } else { + break; + } + } + Ok(self.idx) + } + + fn lex_number(&mut self) -> Result { + // Basic numeric literal: + // digits? [ ('.'|',') digits ] [ (e|E) ('+'|'-')? digits ] + // We store the raw slice. + while matches!(self.peek_char(), Some(c) if c.is_ascii_digit()) { + self.bump_char(); + } + + if let Some(sep) = self.peek_char() { + if (sep == '.' || (sep == ',' && self.dialect.allow_decimal_comma)) + && matches!(self.peek_byte_n(1), Some(b'0'..=b'9')) + { + self.bump_char(); // . or , + while matches!(self.peek_char(), Some(c) if c.is_ascii_digit()) { + self.bump_char(); + } + } + } + + if matches!(self.peek_char(), Some('e' | 'E')) { + // exponent + let save = self.idx; + self.bump_char(); // e/E + if matches!(self.peek_char(), Some('+' | '-')) { + self.bump_char(); + } + if !matches!(self.peek_char(), Some(c) if c.is_ascii_digit()) { + // rollback: treat the 'e' as end of number, not exponent + self.idx = save; + return Ok(self.idx); + } + while matches!(self.peek_char(), Some(c) if c.is_ascii_digit()) { + self.bump_char(); + } + } + + Ok(self.idx) + } +} + +fn is_ident_start(ch: char) -> bool { + ch.is_alphabetic() || ch == '_' +} +fn is_ident_continue(ch: char) -> bool { + ch.is_alphanumeric() || ch == '_' || ch == '.' +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TableName { + pub name: String, + pub quoted: bool, +} +impl TableName { + pub fn unquoted(name: impl Into) -> Self { + Self { + name: name.into(), + quoted: false, + } + } + pub fn quoted(name: impl Into) -> Self { + Self { + name: name.into(), + quoted: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct VarDecl { + pub name: String, + pub expr: Expr, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum UnaryOp { + Plus, + Minus, + Not, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum BinaryOp { + Or, + And, + + Eq, + StrictEq, // == + Neq, + Lt, + Lte, + Gt, + Gte, + + In, + + Concat, // & + Add, + Sub, + Mul, + Div, + Pow, +} + +impl BinaryOp { + fn binding_power(&self) -> (u8, u8) { + // Pratt binding powers: (left_bp, right_bp). + // Left-assoc: (p, p+1). Right-assoc: (p, p). + // + // Precedence per Microsoft DAX operators: + // ^, sign, * /, + -, &, comparisons (=,==,<,>,<=,>=,<>,IN), NOT, &&, || + // + // NOTE: NOT is handled as prefix with its own precedence (see parse_prefix). + match self { + BinaryOp::Or => (1, 2), + BinaryOp::And => (2, 3), + + // comparisons + BinaryOp::Eq + | BinaryOp::StrictEq + | BinaryOp::Neq + | BinaryOp::Lt + | BinaryOp::Lte + | BinaryOp::Gt + | BinaryOp::Gte + | BinaryOp::In => (4, 5), + + BinaryOp::Concat => (5, 6), + + BinaryOp::Add | BinaryOp::Sub => (6, 7), + BinaryOp::Mul | BinaryOp::Div => (7, 8), + + // Right associative, higher precedence than unary sign + BinaryOp::Pow => (9, 9), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum Expr { + Number(String), + String(String), + Boolean(bool), + Blank, + + Parameter(String), // @param (START AT) + + Identifier(String), // variables, etc. + TableRef(TableName), // bare table reference: 'Sales' or Sales + BracketRef(String), // [Measure] / [Column] + TableColumnRef { + table: TableName, + column: String, + }, + HierarchyRef { + table: TableName, + column: String, + levels: Vec, + }, + + FunctionCall { + name: String, + args: Vec, + }, + + Unary { + op: UnaryOp, + expr: Box, + }, + Binary { + op: BinaryOp, + left: Box, + right: Box, + }, + + VarBlock { + decls: Vec, + body: Box, + }, + + // Table constructor: { , , ... } where row is scalar or tuple (..) + // Stored as rows of expressions (columns per row). + TableConstructor(Vec>), + + Paren(Box), +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct FuncParam { + pub name: String, + /// Raw type-hint tokens after `:` (0..N identifiers), e.g. `NUMERIC`, or `Scalar Numeric expr`. + pub type_hints: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Query { + pub define: Option, + pub evaluates: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct DefineBlock { + pub defs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum Definition { + Measure { + doc: Option, + table: Option, + name: String, + expr: Expr, + }, + Var { + doc: Option, + name: String, + expr: Expr, + }, + Table { + doc: Option, + name: String, + expr: Expr, + }, + Column { + doc: Option, + table: Option, + name: String, + expr: Expr, + }, + Function { + doc: Option, + name: String, + params: Vec, + body: Expr, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum SortDirection { + Asc, + Desc, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct OrderKey { + pub expr: Expr, + pub direction: SortDirection, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct EvaluateStmt { + pub expr: Expr, + pub order_by: Vec, + pub start_at: Option>, +} + +pub struct Parser { + tokens: Vec, + i: usize, + dialect: Dialect, +} +impl Parser { + pub fn new(tokens: Vec, dialect: Dialect) -> Self { + Self { + tokens, + i: 0, + dialect, + } + } + + fn peek(&self) -> &Token { + self.tokens.get(self.i).unwrap_or_else(|| { + self.tokens + .last() + .expect("token stream should always end with EOF") + }) + } + + fn bump(&mut self) -> Token { + let tok = self.peek().clone(); + if !matches!(tok.kind, TokenKind::Eof) { + self.i += 1; + } + tok + } + + fn same_variant(a: &TokenKind, b: &TokenKind) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) + } + + fn peek_is(&self, kind: TokenKind) -> bool { + Self::same_variant(&self.peek().kind, &kind) + } + + fn eat(&mut self, kind: TokenKind) -> Option { + if self.peek_is(kind.clone()) { + Some(self.bump()) + } else { + None + } + } + + fn expect(&mut self, kind: TokenKind, what: &'static str) -> Result { + self.eat(kind.clone()).ok_or_else(|| ParseError { + message: format!("expected {what}"), + span: self.peek().span, + }) + } + + fn peek_ident_text(&self) -> Option<&str> { + match &self.peek().kind { + TokenKind::Ident(s) => Some(s.as_str()), + _ => None, + } + } + + fn peek_kw(&self, kw: &str) -> bool { + self.peek_ident_text() + .is_some_and(|s| s.eq_ignore_ascii_case(kw)) + } + + fn eat_kw(&mut self, kw: &str) -> bool { + if self.peek_kw(kw) { + self.bump(); + true + } else { + false + } + } + + fn expect_kw(&mut self, kw: &'static str) -> Result<(), ParseError> { + if self.eat_kw(kw) { + Ok(()) + } else { + Err(ParseError { + message: format!("expected keyword {kw}"), + span: self.peek().span, + }) + } + } + + fn expect_ident(&mut self, what: &'static str) -> Result { + match self.peek().kind.clone() { + TokenKind::Ident(s) => { + self.bump(); + Ok(s) + } + _ => Err(ParseError { + message: format!("expected {what}"), + span: self.peek().span, + }), + } + } + + fn expect_bracket_ident(&mut self, what: &'static str) -> Result { + match self.peek().kind.clone() { + TokenKind::BracketIdent(s) => { + self.bump(); + Ok(s) + } + _ => Err(ParseError { + message: format!("expected {what}"), + span: self.peek().span, + }), + } + } + + fn expect_eof(&mut self) -> Result<(), ParseError> { + if matches!(self.peek().kind, TokenKind::Eof) { + Ok(()) + } else { + Err(ParseError { + message: "expected end of input".into(), + span: self.peek().span, + }) + } + } + + fn eat_separator(&mut self) -> bool { + if self.eat(TokenKind::Comma).is_some() { + true + } else { + self.dialect.allow_semicolon_separators && self.eat(TokenKind::Semicolon).is_some() + } + } + + fn consume_stmt_terminators(&mut self) { + // allow optional `;` between DEFINE entities / between EVALUATE statements + while self.eat(TokenKind::Semicolon).is_some() {} + } + + fn skip_doc_comments(&mut self) { + while matches!(self.peek().kind, TokenKind::DocComment(_)) { + self.bump(); + } + } + + fn take_doc_comments(&mut self) -> Option { + let mut parts: Vec = Vec::new(); + while let TokenKind::DocComment(s) = self.peek().kind.clone() { + self.bump(); + parts.push(s); + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } + } + + fn parse_table_name(&mut self) -> Result { + match self.peek().kind.clone() { + TokenKind::QuotedIdent(s) => { + self.bump(); + Ok(TableName::quoted(s)) + } + TokenKind::Ident(s) => { + self.bump(); + Ok(TableName::unquoted(s)) + } + _ => Err(ParseError { + message: "expected table name (identifier or single-quoted identifier)".into(), + span: self.peek().span, + }), + } + } + + // ---- public entrypoints ---- + + pub fn parse_formula_expression(&mut self) -> Result { + self.skip_doc_comments(); + // Optional leading '=' (Excel/Power Pivot convention). + self.eat(TokenKind::Eq); + let expr = self.parse_expr_bp(0)?; + self.expect_eof()?; + Ok(expr) + } + + pub fn parse_query(&mut self) -> Result { + self.skip_doc_comments(); + + let define = if self.peek_kw("define") { + Some(self.parse_define_block()?) + } else { + None + }; + + let mut evaluates = Vec::new(); + loop { + self.consume_stmt_terminators(); + self.skip_doc_comments(); + if self.peek_kw("evaluate") { + evaluates.push(self.parse_evaluate_stmt()?); + } else { + break; + } + } + + if evaluates.is_empty() { + return Err(ParseError { + message: "expected at least one EVALUATE statement".into(), + span: self.peek().span, + }); + } + + self.consume_stmt_terminators(); + self.skip_doc_comments(); + self.expect_eof()?; + Ok(Query { define, evaluates }) + } + + // ---- query parsing ---- + + fn parse_define_block(&mut self) -> Result { + self.expect_kw("define")?; + let mut defs = Vec::new(); + + loop { + self.consume_stmt_terminators(); + + // DEFINE ends before first EVALUATE + if self.peek_kw("evaluate") || matches!(self.peek().kind, TokenKind::Eof) { + break; + } + + let doc = self.take_doc_comments(); + + // If doc comments were followed by EVALUATE/EOF, just ignore them (treat as trivia). + if self.peek_kw("evaluate") || matches!(self.peek().kind, TokenKind::Eof) { + break; + } + + if self.peek_kw("measure") { + defs.push(self.parse_define_measure(doc)?); + } else if self.peek_kw("function") { + defs.push(self.parse_define_function(doc)?); + } else if self.peek_kw("var") { + defs.push(self.parse_define_var(doc)?); + } else if self.peek_kw("table") { + defs.push(self.parse_define_table(doc)?); + } else if self.peek_kw("column") { + defs.push(self.parse_define_column(doc)?); + } else { + return Err(ParseError { + message: "expected MEASURE, FUNCTION, VAR, TABLE, or COLUMN in DEFINE block" + .into(), + span: self.peek().span, + }); + } + } + + if defs.is_empty() { + return Err(ParseError { + message: "DEFINE block must contain at least one definition".into(), + span: self.peek().span, + }); + } + + Ok(DefineBlock { defs }) + } + + fn parse_define_measure(&mut self, doc: Option) -> Result { + self.expect_kw("measure")?; + + // Typically: MEASURE 'Table'[Measure] = + // We accept: + // MEASURE [Measure] = ... + // MEASURE 'T'[M] = ... + // MEASURE T[M] = ... + let (table, name) = if matches!(self.peek().kind, TokenKind::BracketIdent(_)) { + ( + None, + self.expect_bracket_ident("measure name like [My Measure]")?, + ) + } else { + let t = self.parse_table_name()?; + let n = self.expect_bracket_ident("measure name like [My Measure]")?; + (Some(t), n) + }; + + self.expect(TokenKind::Eq, "`=`")?; + let expr = self.parse_expr_bp(0)?; + + self.consume_stmt_terminators(); + self.ensure_stmt_follower(&["measure", "function", "var", "table", "column", "evaluate"])?; + + Ok(Definition::Measure { + doc, + table, + name, + expr, + }) + } + + fn parse_define_var(&mut self, doc: Option) -> Result { + self.expect_kw("var")?; + let name = self.expect_ident("variable name")?; + self.expect(TokenKind::Eq, "`=`")?; + let expr = self.parse_expr_bp(0)?; + + self.consume_stmt_terminators(); + self.ensure_stmt_follower(&["measure", "function", "var", "table", "column", "evaluate"])?; + + Ok(Definition::Var { doc, name, expr }) + } + + fn parse_define_table(&mut self, doc: Option) -> Result { + self.expect_kw("table")?; + // Spec uses `` — allow identifier or single-quoted identifier. + let name = match self.peek().kind.clone() { + TokenKind::Ident(s) => { + self.bump(); + s + } + TokenKind::QuotedIdent(s) => { + self.bump(); + s + } + _ => { + return Err(ParseError { + message: "expected table name for TABLE definition".into(), + span: self.peek().span, + }) + } + }; + + self.expect(TokenKind::Eq, "`=`")?; + let expr = self.parse_expr_bp(0)?; + + self.consume_stmt_terminators(); + self.ensure_stmt_follower(&["measure", "function", "var", "table", "column", "evaluate"])?; + + Ok(Definition::Table { doc, name, expr }) + } + + fn parse_define_column(&mut self, doc: Option) -> Result { + self.expect_kw("column")?; + + // Common: COLUMN 'Table'[Column] = + let (table, name) = if matches!(self.peek().kind, TokenKind::BracketIdent(_)) { + ( + None, + self.expect_bracket_ident("column name like [My Column]")?, + ) + } else { + let t = self.parse_table_name()?; + let n = self.expect_bracket_ident("column name like [My Column]")?; + (Some(t), n) + }; + + self.expect(TokenKind::Eq, "`=`")?; + let expr = self.parse_expr_bp(0)?; + + self.consume_stmt_terminators(); + self.ensure_stmt_follower(&["measure", "function", "var", "table", "column", "evaluate"])?; + + Ok(Definition::Column { + doc, + table, + name, + expr, + }) + } + + fn parse_define_function(&mut self, doc: Option) -> Result { + // FUNCTION = ([parameter name]: [parameter type], ...) => + self.expect_kw("function")?; + let name = self.expect_ident("function name")?; + + self.expect(TokenKind::Eq, "`=`")?; + self.expect(TokenKind::LParen, "`(`")?; + + let mut params: Vec = Vec::new(); + if !self.peek_is(TokenKind::RParen) { + loop { + let pname = self.expect_ident("parameter name")?; + + let mut type_hints: Vec = Vec::new(); + if self.eat(TokenKind::Colon).is_some() { + // DAX UDF type hints can be 1..N identifiers (e.g. `NUMERIC` or `Scalar Numeric expr`). + // Parse until `,`/`;` or `)`. + while let TokenKind::Ident(s) = self.peek().kind.clone() { + self.bump(); + type_hints.push(s); + } + + if type_hints.is_empty() { + return Err(ParseError { + message: "expected at least one type hint after ':'".into(), + span: self.peek().span, + }); + } + } + + params.push(FuncParam { + name: pname, + type_hints, + }); + + if self.eat_separator() { + if self.peek_is(TokenKind::RParen) { + return Err(ParseError { + message: "trailing separator in FUNCTION parameter list".into(), + span: self.peek().span, + }); + } + continue; + } + + break; + } + } + + self.expect(TokenKind::RParen, "`)`")?; + self.expect(TokenKind::Arrow, "`=>`")?; + + let body = self.parse_expr_bp(0)?; + + self.consume_stmt_terminators(); + self.ensure_stmt_follower(&["measure", "function", "var", "table", "column", "evaluate"])?; + + Ok(Definition::Function { + doc, + name, + params, + body, + }) + } + + fn parse_evaluate_stmt(&mut self) -> Result { + self.expect_kw("evaluate")?; + + let expr = self.parse_expr_bp(0)?; + + let mut order_by = Vec::new(); + if self.peek_kw("order") { + order_by = self.parse_order_by_clause()?; + } + + let mut start_at = None; + if self.peek_kw("start") { + if order_by.is_empty() { + return Err(ParseError { + message: "START AT requires an ORDER BY clause".into(), + span: self.peek().span, + }); + } + + let values = self.parse_start_at_clause()?; + + // Spec: values must be constant or @param; count <= ORDER BY expressions. + if values.len() > order_by.len() { + return Err(ParseError { + message: "START AT has more arguments than ORDER BY".into(), + span: self.peek().span, + }); + } + + start_at = Some(values); + } + + self.consume_stmt_terminators(); + self.ensure_stmt_follower(&["evaluate"])?; + + Ok(EvaluateStmt { + expr, + order_by, + start_at, + }) + } + + fn parse_order_by_clause(&mut self) -> Result, ParseError> { + self.expect_kw("order")?; + self.expect_kw("by")?; + + let mut keys = Vec::new(); + loop { + let expr = self.parse_expr_bp(0)?; + + let direction = if self.peek_kw("asc") { + self.bump(); + SortDirection::Asc + } else if self.peek_kw("desc") { + self.bump(); + SortDirection::Desc + } else { + SortDirection::Asc + }; + + keys.push(OrderKey { expr, direction }); + + if self.eat_separator() { + continue; + } + break; + } + + Ok(keys) + } + + fn parse_start_at_clause(&mut self) -> Result, ParseError> { + self.expect_kw("start")?; + self.expect_kw("at")?; + + let mut values = Vec::new(); + loop { + values.push(self.parse_expr_bp(0)?); + if self.eat_separator() { + continue; + } + break; + } + + Ok(values) + } + + fn ensure_stmt_follower(&self, allowed_keywords: &[&str]) -> Result<(), ParseError> { + // After parsing a "statement-sized" expression, the next token must be: + // - EOF + // - doc comment (we treat as ignorable trivia between statements) + // - one of the allowed statement starters (contextual keywords) + if matches!(self.peek().kind, TokenKind::Eof) { + return Ok(()); + } + if matches!(self.peek().kind, TokenKind::DocComment(_)) { + return Ok(()); + } + if let Some(id) = self.peek_ident_text() { + let ok = allowed_keywords + .iter() + .any(|kw| id.eq_ignore_ascii_case(kw)); + if ok { + return Ok(()); + } + } + Err(ParseError { + message: "unexpected token after statement".into(), + span: self.peek().span, + }) + } + + // ---- expression parsing (Pratt) ---- + + fn parse_expr_bp(&mut self, min_bp: u8) -> Result { + let mut lhs = self.parse_prefix()?; + + while let Some((op, lbp, rbp)) = self.peek_infix_op() { + if lbp < min_bp { + break; + } + + // consume operator token/keyword + match op { + BinaryOp::In => { + // IN is an identifier token + self.bump(); + } + _ => { + self.bump(); + } + } + + let rhs = self.parse_expr_bp(rbp)?; + lhs = Expr::Binary { + op, + left: Box::new(lhs), + right: Box::new(rhs), + }; + } + + Ok(lhs) + } + + fn parse_prefix(&mut self) -> Result { + // VAR blocks are expressions (not just top-level) + if self.peek_kw("var") { + return self.parse_var_block(); + } + + // unary operators + // + // IMPORTANT: precedence per MS docs: exponentiation (^) happens before unary sign. + // So unary sign must bind *less tightly* than '^' but tighter than '* /'. + if self.eat(TokenKind::Plus).is_some() { + let expr = self.parse_expr_bp(8)?; + return Ok(Expr::Unary { + op: UnaryOp::Plus, + expr: Box::new(expr), + }); + } + if self.eat(TokenKind::Minus).is_some() { + let expr = self.parse_expr_bp(8)?; + return Ok(Expr::Unary { + op: UnaryOp::Minus, + expr: Box::new(expr), + }); + } + + // IMPORTANT: precedence per MS docs: comparisons bind tighter than NOT, but NOT binds + // tighter than && / ||. + if self.peek_kw("not") { + self.bump(); + let expr = self.parse_expr_bp(3)?; + return Ok(Expr::Unary { + op: UnaryOp::Not, + expr: Box::new(expr), + }); + } + + self.parse_primary() + } + + fn peek_infix_op(&self) -> Option<(BinaryOp, u8, u8)> { + let op = match &self.peek().kind { + TokenKind::OrOr => BinaryOp::Or, + TokenKind::AndAnd => BinaryOp::And, + + TokenKind::Eq => BinaryOp::Eq, + TokenKind::EqEq => BinaryOp::StrictEq, + TokenKind::Neq => BinaryOp::Neq, + TokenKind::Lt => BinaryOp::Lt, + TokenKind::Lte => BinaryOp::Lte, + TokenKind::Gt => BinaryOp::Gt, + TokenKind::Gte => BinaryOp::Gte, + + TokenKind::Amp => BinaryOp::Concat, + + TokenKind::Plus => BinaryOp::Add, + TokenKind::Minus => BinaryOp::Sub, + TokenKind::Star => BinaryOp::Mul, + TokenKind::Slash => BinaryOp::Div, + TokenKind::Caret => BinaryOp::Pow, + + TokenKind::Ident(s) if s.eq_ignore_ascii_case("in") => BinaryOp::In, + + _ => return None, + }; + + let (lbp, rbp) = op.binding_power(); + Some((op, lbp, rbp)) + } + + fn parse_hierarchy_tail( + &mut self, + table: TableName, + column: String, + ) -> Result { + if !self.peek_is(TokenKind::Dot) { + return Ok(Expr::TableColumnRef { table, column }); + } + + let mut levels = Vec::new(); + while self.eat(TokenKind::Dot).is_some() { + let level = self.expect_bracket_ident("hierarchy level like [Year]")?; + levels.push(level); + } + + Ok(Expr::HierarchyRef { + table, + column, + levels, + }) + } + + fn parse_primary(&mut self) -> Result { + match self.peek().kind.clone() { + TokenKind::Number(n) => { + self.bump(); + Ok(Expr::Number(n)) + } + TokenKind::String(s) => { + self.bump(); + Ok(Expr::String(s)) + } + TokenKind::Param(p) => { + self.bump(); + Ok(Expr::Parameter(p)) + } + TokenKind::BracketIdent(name) => { + self.bump(); + Ok(Expr::BracketRef(name)) + } + TokenKind::QuotedIdent(name) => { + self.bump(); + let table = TableName::quoted(name); + + // 'Table'[Column] + if let TokenKind::BracketIdent(col) = self.peek().kind.clone() { + self.bump(); + self.parse_hierarchy_tail(table, col) + } else { + Ok(Expr::TableRef(table)) + } + } + TokenKind::Ident(id) => { + // identifier could be: + // - function call: IDENT '(' ... + // - table/column reference: IDENT '[' col ']' + // - bare identifier: variable/table + self.bump(); + + if self.peek_is(TokenKind::LParen) { + self.bump(); // ( + let args = self.parse_arg_list()?; + return Ok(Expr::FunctionCall { name: id, args }); + } + + let table = TableName::unquoted(id.clone()); + if let TokenKind::BracketIdent(col) = self.peek().kind.clone() { + self.bump(); + return self.parse_hierarchy_tail(table, col); + } + + // contextual literals (after call/column checks so TRUE() / BLANK() parse) + if id.eq_ignore_ascii_case("true") { + return Ok(Expr::Boolean(true)); + } + if id.eq_ignore_ascii_case("false") { + return Ok(Expr::Boolean(false)); + } + // DAX's "blank" is usually BLANK(), but some tooling treats BLANK as a literal-ish value. + if id.eq_ignore_ascii_case("blank") { + return Ok(Expr::Blank); + } + + Ok(Expr::Identifier(id)) + } + TokenKind::LParen => { + self.bump(); + let inner = self.parse_expr_bp(0)?; + self.expect(TokenKind::RParen, "`)`")?; + Ok(Expr::Paren(Box::new(inner))) + } + TokenKind::LBrace => self.parse_table_constructor(), + TokenKind::DocComment(_) => { + // Treat doc comments as trivia; skip and parse next primary. + self.bump(); + self.parse_primary() + } + TokenKind::Eof => Err(ParseError { + message: "unexpected end of input".into(), + span: self.peek().span, + }), + _ => Err(ParseError { + message: "expected expression".into(), + span: self.peek().span, + }), + } + } + + fn parse_arg_list(&mut self) -> Result, ParseError> { + // assumes '(' already consumed + if self.peek_is(TokenKind::RParen) { + self.bump(); + return Ok(Vec::new()); + } + + let mut args = Vec::new(); + loop { + let expr = self.parse_expr_bp(0)?; + args.push(expr); + + if self.eat_separator() { + // disallow trailing separator: must have another expr next + if self.peek_is(TokenKind::RParen) { + return Err(ParseError { + message: "trailing argument separator".into(), + span: self.peek().span, + }); + } + continue; + } + + break; + } + + self.expect(TokenKind::RParen, "`)`")?; + Ok(args) + } + + fn parse_var_block(&mut self) -> Result { + // VAR = [VAR ...] RETURN + let mut decls = Vec::new(); + + if !self.peek_kw("var") { + return Err(ParseError { + message: "expected VAR".into(), + span: self.peek().span, + }); + } + + while self.eat_kw("var") { + let name = self.expect_ident("variable name")?; + self.expect(TokenKind::Eq, "`=`")?; + let expr = self.parse_expr_bp(0)?; + decls.push(VarDecl { name, expr }); + } + + self.expect_kw("return")?; + let body = self.parse_expr_bp(0)?; + + Ok(Expr::VarBlock { + decls, + body: Box::new(body), + }) + } + + fn parse_table_constructor(&mut self) -> Result { + // { row (, row)* } + // row := scalar_expr | '(' expr (, expr)* ')' + self.expect(TokenKind::LBrace, "`{`")?; + + if self.peek_is(TokenKind::RBrace) { + self.bump(); + return Ok(Expr::TableConstructor(Vec::new())); + } + + let mut rows: Vec> = Vec::new(); + + loop { + let row = if self.peek_is(TokenKind::LParen) { + self.bump(); // ( + if self.peek_is(TokenKind::RParen) { + return Err(ParseError { + message: "empty tuple row in table constructor".into(), + span: self.peek().span, + }); + } + + let mut cols = Vec::new(); + loop { + cols.push(self.parse_expr_bp(0)?); + if self.eat_separator() { + if self.peek_is(TokenKind::RParen) { + return Err(ParseError { + message: "trailing separator in tuple row".into(), + span: self.peek().span, + }); + } + continue; + } + break; + } + + self.expect(TokenKind::RParen, "`)`")?; + cols + } else { + vec![self.parse_expr_bp(0)?] + }; + + rows.push(row); + + if self.eat_separator() { + if self.peek_is(TokenKind::RBrace) { + return Err(ParseError { + message: "trailing separator in table constructor".into(), + span: self.peek().span, + }); + } + continue; + } + + break; + } + + self.expect(TokenKind::RBrace, "`}`")?; + Ok(Expr::TableConstructor(rows)) + } +} + +// ---- convenience API ---- + +pub fn lex(input: &str) -> Result, DaxError> { + lex_with_dialect(input, Dialect::default()) +} + +pub fn lex_with_dialect(input: &str, dialect: Dialect) -> Result, DaxError> { + Lexer::new(input, dialect).lex_all().map_err(DaxError::Lex) +} + +pub fn parse_expression(input: &str) -> Result { + parse_expression_with_dialect(input, Dialect::default()) +} + +pub fn parse_expression_with_dialect(input: &str, dialect: Dialect) -> Result { + let tokens = Lexer::new(input, dialect) + .lex_all() + .map_err(DaxError::Lex)?; + let mut p = Parser::new(tokens, dialect); + p.parse_formula_expression().map_err(DaxError::Parse) +} + +pub fn parse_query(input: &str) -> Result { + parse_query_with_dialect(input, Dialect::default()) +} + +pub fn parse_query_with_dialect(input: &str, dialect: Dialect) -> Result { + let tokens = Lexer::new(input, dialect) + .lex_all() + .map_err(DaxError::Lex)?; + let mut p = Parser::new(tokens, dialect); + p.parse_query().map_err(DaxError::Parse) +} + +// ---- tests ---- + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! num { + ($s:expr) => { + Expr::Number($s.to_string()) + }; + } + macro_rules! strlit { + ($s:expr) => { + Expr::String($s.to_string()) + }; + } + macro_rules! ident { + ($s:expr) => { + Expr::Identifier($s.to_string()) + }; + } + macro_rules! param { + ($s:expr) => { + Expr::Parameter($s.to_string()) + }; + } + macro_rules! br { + ($s:expr) => { + Expr::BracketRef($s.to_string()) + }; + } + macro_rules! qtbl { + ($s:expr) => { + TableName::quoted($s.to_string()) + }; + } + macro_rules! utbl { + ($s:expr) => { + TableName::unquoted($s.to_string()) + }; + } + macro_rules! bin { + ($op:expr, $l:expr, $r:expr) => { + Expr::Binary { + op: $op, + left: Box::new($l), + right: Box::new($r), + } + }; + } + macro_rules! un { + ($op:expr, $e:expr) => { + Expr::Unary { + op: $op, + expr: Box::new($e), + } + }; + } + + #[test] + fn lex_bracket_escape() { + let toks = lex("[a]]b]").unwrap(); + assert_eq!(toks.len(), 2); // ident + eof + match &toks[0].kind { + TokenKind::BracketIdent(s) => assert_eq!(s, "a]b"), + _ => panic!("expected bracket ident"), + } + } + + #[test] + fn lex_string_escape() { + let toks = lex(r#""a""b""#).unwrap(); + match &toks[0].kind { + TokenKind::String(s) => assert_eq!(s, r#"a"b"#), + _ => panic!("expected string"), + } + } + + #[test] + fn comments_are_skipped() { + let e = parse_expression("1 + 2 -- hello\n * 3").unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::Add, + num!("1"), + bin!(BinaryOp::Mul, num!("2"), num!("3")) + ) + ); + } + + #[test] + fn precedence_mul_over_add() { + let e = parse_expression("1 + 2 * 3").unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::Add, + num!("1"), + bin!(BinaryOp::Mul, num!("2"), num!("3")) + ) + ); + } + + #[test] + fn right_assoc_pow() { + let e = parse_expression("2 ^ 3 ^ 4").unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::Pow, + num!("2"), + bin!(BinaryOp::Pow, num!("3"), num!("4")) + ) + ); + } + + #[test] + fn unary_minus_binds_between_pow_and_mul() { + // DAX precedence: exponentiation before sign + // So -2^2 == -(2^2) + let e = parse_expression("-2^2").unwrap(); + assert_eq!( + e, + un!(UnaryOp::Minus, bin!(BinaryOp::Pow, num!("2"), num!("2"))) + ); + + // but sign still binds tighter than multiplication: -2*3 == (-2)*3 + let e2 = parse_expression("-2 * 3").unwrap(); + assert_eq!( + e2, + bin!(BinaryOp::Mul, un!(UnaryOp::Minus, num!("2")), num!("3")) + ); + } + + #[test] + fn not_binds_looser_than_comparisons_but_tighter_than_and_or() { + let e = parse_expression("not 1 = 2").unwrap(); + assert_eq!( + e, + un!(UnaryOp::Not, bin!(BinaryOp::Eq, num!("1"), num!("2"))) + ); + + let e2 = parse_expression("not true && false").unwrap(); + assert_eq!( + e2, + bin!( + BinaryOp::And, + un!(UnaryOp::Not, Expr::Boolean(true)), + Expr::Boolean(false) + ) + ); + } + + #[test] + fn strict_equality_operator() { + let e = parse_expression("1 == 2").unwrap(); + assert_eq!(e, bin!(BinaryOp::StrictEq, num!("1"), num!("2"))); + } + + #[test] + fn leading_dot_number_literal() { + let e = parse_expression(".20 * 3").unwrap(); + assert_eq!(e, bin!(BinaryOp::Mul, num!(".20"), num!("3"))); + } + + #[test] + fn var_block_parses() { + let e = parse_expression("var x = 1 var y = x + 2 return y * 3").unwrap(); + assert_eq!( + e, + Expr::VarBlock { + decls: vec![ + VarDecl { + name: "x".into(), + expr: num!("1"), + }, + VarDecl { + name: "y".into(), + expr: bin!(BinaryOp::Add, ident!("x"), num!("2")), + }, + ], + body: Box::new(bin!(BinaryOp::Mul, ident!("y"), num!("3"))), + } + ); + } + + #[test] + fn function_call_args() { + let e = parse_expression(r#"sumx('sales', 'sales'[amount] + 1)"#).unwrap(); + assert_eq!( + e, + Expr::FunctionCall { + name: "sumx".into(), + args: vec![ + Expr::TableRef(qtbl!("sales")), + bin!( + BinaryOp::Add, + Expr::TableColumnRef { + table: qtbl!("sales"), + column: "amount".into(), + }, + num!("1") + ) + ], + } + ); + } + + #[test] + fn table_constructor_scalar_rows() { + let e = parse_expression("{1, 2, 3}").unwrap(); + assert_eq!( + e, + Expr::TableConstructor(vec![vec![num!("1")], vec![num!("2")], vec![num!("3")]]) + ); + } + + #[test] + fn table_constructor_tuple_rows() { + let e = parse_expression("{(1, 2), (3, 4)}").unwrap(); + assert_eq!( + e, + Expr::TableConstructor(vec![vec![num!("1"), num!("2")], vec![num!("3"), num!("4")]]) + ); + } + + #[test] + fn table_and_bracket_ref() { + let e = parse_expression("'Sales'[Amount] & [Total Sales]").unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::Concat, + Expr::TableColumnRef { + table: qtbl!("Sales"), + column: "Amount".into() + }, + br!("Total Sales") + ) + ); + } + + #[test] + fn parse_query_define_and_evaluate() { + let q = parse_query( + "define + measure 't'[m] = 1 + var v = 2 + evaluate + 't' + order by + [m] desc + start at + 5", + ) + .unwrap(); + + assert_eq!( + q, + Query { + define: Some(DefineBlock { + defs: vec![ + Definition::Measure { + doc: None, + table: Some(qtbl!("t")), + name: "m".into(), + expr: num!("1"), + }, + Definition::Var { + doc: None, + name: "v".into(), + expr: num!("2"), + } + ] + }), + evaluates: vec![EvaluateStmt { + expr: Expr::TableRef(qtbl!("t")), + order_by: vec![OrderKey { + expr: br!("m"), + direction: SortDirection::Desc + }], + start_at: Some(vec![num!("5")]), + }] + } + ); + } + + #[test] + fn parse_query_multiple_evaluate_and_semicolons() { + let q = parse_query("evaluate { [m] }; evaluate 't';").unwrap(); + assert_eq!(q.evaluates.len(), 2); + assert_eq!( + q.evaluates[0].expr, + Expr::TableConstructor(vec![vec![br!("m")]]) + ); + assert_eq!(q.evaluates[1].expr, Expr::TableRef(qtbl!("t"))); + } + + #[test] + fn start_at_requires_order_by() { + let err = parse_query("evaluate 't' start at 1").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("START AT requires an ORDER BY"), "got: {msg}"); + } + + #[test] + fn start_at_arg_count_must_not_exceed_order_by() { + let err = parse_query("evaluate 't' order by [a] start at 1, 2").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("more arguments"), "got: {msg}"); + } + + #[test] + fn start_at_allows_expression_args() { + let q = parse_query("evaluate 't' order by [a] start at [x]").unwrap(); + assert_eq!(q.evaluates[0].start_at, Some(vec![br!("x")])); + } + + #[test] + fn start_at_allows_at_param() { + let q = parse_query("evaluate 't' order by [a] start at @p").unwrap(); + assert_eq!(q.evaluates[0].start_at, Some(vec![param!("p")])); + } + + #[test] + fn define_function_udf_parses_with_doc() { + let q = parse_query( + "define + /// adds two numbers + function sumtwo = ( a, b : numeric ) => a + b + evaluate + { sumtwo(10, 20) }", + ) + .unwrap(); + + assert_eq!( + q.define.unwrap().defs[0], + Definition::Function { + doc: Some("adds two numbers".into()), + name: "sumtwo".into(), + params: vec![ + FuncParam { + name: "a".into(), + type_hints: vec![], + }, + FuncParam { + name: "b".into(), + type_hints: vec!["numeric".into()], + } + ], + body: bin!(BinaryOp::Add, ident!("a"), ident!("b")), + } + ); + } + + #[test] + fn in_operator() { + let e = parse_expression("[x] in {1,2,3}").unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::In, + br!("x"), + Expr::TableConstructor(vec![vec![num!("1")], vec![num!("2")], vec![num!("3")]]) + ) + ); + } + + #[test] + fn leading_equals_is_accepted() { + let e = parse_expression("=1+2").unwrap(); + assert_eq!(e, bin!(BinaryOp::Add, num!("1"), num!("2"))); + } + + #[test] + fn semicolon_separators() { + let dialect = Dialect { + allow_semicolon_separators: true, + ..Default::default() + }; + + let e = parse_expression_with_dialect("sum(1; 2; 3)", dialect).unwrap(); + assert_eq!( + e, + Expr::FunctionCall { + name: "sum".into(), + args: vec![num!("1"), num!("2"), num!("3")] + } + ); + } + + #[test] + fn errors_on_trailing_arg_separator() { + let err = parse_expression("sum(1, )").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("trailing argument separator"), "got: {msg}"); + } + + #[test] + fn errors_on_unterminated_string() { + let err = parse_expression(r#""oops"#).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("unterminated"), "got: {msg}"); + } + + #[test] + fn errors_on_unexpected_after_statement_in_define() { + // expression parser would parse "1" then next token "2" is not a valid stmt starter -> error + let err = parse_query("define measure 't'[m] = 1 2 evaluate 't'").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("unexpected token after statement"), + "got: {msg}" + ); + } + + #[test] + fn errors_on_empty_evaluate() { + let err = parse_query("define var x = 1").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("expected at least one EVALUATE"), "got: {msg}"); + } + + #[test] + fn identifiers_can_be_tables_unquoted() { + let e = parse_expression("sales").unwrap(); + // In real DAX, this may refer to a table; we keep it as Identifier for now. + // (You can later add a resolution phase that rewrites Identifier->TableRef) + assert_eq!(e, ident!("sales")); + } + + #[test] + fn quoted_table_ref_is_table_ref() { + let e = parse_expression("'Sales'").unwrap(); + assert_eq!(e, Expr::TableRef(qtbl!("Sales"))); + } + + #[test] + fn parens() { + let e = parse_expression("(1 + 2) * 3").unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::Mul, + Expr::Paren(Box::new(bin!(BinaryOp::Add, num!("1"), num!("2")))), + num!("3") + ) + ); + } + + #[test] + fn logical_ops() { + let e = parse_expression("true && false || true").unwrap(); + // && binds tighter than || + assert_eq!( + e, + bin!( + BinaryOp::Or, + bin!(BinaryOp::And, Expr::Boolean(true), Expr::Boolean(false)), + Expr::Boolean(true) + ) + ); + } + + #[test] + fn comparisons_chain_left_assoc() { + let e = parse_expression("1 = 2 = 3").unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::Eq, + bin!(BinaryOp::Eq, num!("1"), num!("2")), + num!("3") + ) + ); + } + + #[test] + fn concat_precedence_between_add_and_compare() { + let e = parse_expression(r#""a" & "b" = "ab""#).unwrap(); + assert_eq!( + e, + bin!( + BinaryOp::Eq, + bin!(BinaryOp::Concat, strlit!("a"), strlit!("b")), + strlit!("ab") + ) + ); + } + + #[test] + fn table_column_ref_unquoted_table() { + let e = parse_expression("t[amount]").unwrap(); + assert_eq!( + e, + Expr::TableColumnRef { + table: utbl!("t"), + column: "amount".into() + } + ); + } +} diff --git a/crates/dax-parser/tests/corpus.rs b/crates/dax-parser/tests/corpus.rs new file mode 100644 index 00000000..1424f1ec --- /dev/null +++ b/crates/dax-parser/tests/corpus.rs @@ -0,0 +1,95 @@ +use dax_parser::{lex, parse_expression, TokenKind}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crate should be nested under repo root") + .to_path_buf() +} + +fn load_blocks(path: &Path) -> Vec<(String, String)> { + let text = fs::read_to_string(path).expect("fixture file missing"); + let mut blocks = Vec::new(); + let mut current = Vec::new(); + + for line in text.lines() { + if line.trim() == "---" { + if !current.is_empty() { + blocks.push(current.join("\n")); + current.clear(); + } + continue; + } + current.push(line.to_string()); + } + if !current.is_empty() { + blocks.push(current.join("\n")); + } + + let mut out = Vec::new(); + for block in blocks { + let mut source = "".to_string(); + let mut expr_lines = Vec::new(); + for line in block.lines() { + if let Some(rest) = line.strip_prefix("# source:") { + source = rest.trim().to_string(); + continue; + } + expr_lines.push(line); + } + let expr = expr_lines.join("\n").trim().to_string(); + if expr.is_empty() { + continue; + } + out.push((source, expr)); + } + out +} + +#[test] +fn parse_expression_corpus_pydaxlexer() { + let path = repo_root().join("tests/dax/fixtures/pydaxlexer/expressions.txt"); + for (source, expr) in load_blocks(&path) { + parse_expression(&expr) + .unwrap_or_else(|err| panic!("pydaxlexer expression failed: {source}\n{expr}\n{err}")); + } +} + +#[test] +fn parse_expression_corpus_pydaxlexer_stress() { + let path = repo_root().join("tests/dax/fixtures/pydaxlexer/stress.txt"); + for (source, expr) in load_blocks(&path) { + parse_expression(&expr).unwrap_or_else(|err| { + panic!("pydaxlexer stress expression failed: {source}\n{expr}\n{err}") + }); + } +} + +#[test] +fn parse_expression_corpus_pbi_parsers() { + let path = repo_root().join("tests/dax/fixtures/pbi_parsers/expressions.txt"); + for (source, expr) in load_blocks(&path) { + parse_expression(&expr) + .unwrap_or_else(|err| panic!("pbi_parsers expression failed: {source}\n{expr}\n{err}")); + } +} + +#[test] +fn lex_tabular_editor_keywords() { + let path = repo_root().join("tests/dax/fixtures/tabulareditor/keywords.txt"); + let text = fs::read_to_string(path).expect("keywords fixture missing"); + for kw in text.lines() { + let kw = kw.trim(); + if kw.is_empty() { + continue; + } + let tokens = lex(kw).unwrap_or_else(|err| panic!("keyword lex failed: {kw}\n{err}")); + match &tokens[0].kind { + TokenKind::Ident(value) => assert_eq!(value, kw), + other => panic!("keyword did not lex as ident: {kw} -> {other:?}"), + } + } +} diff --git a/crates/dax-parser/tests/corpus_errors.rs b/crates/dax-parser/tests/corpus_errors.rs new file mode 100644 index 00000000..19a8845d --- /dev/null +++ b/crates/dax-parser/tests/corpus_errors.rs @@ -0,0 +1,40 @@ +use dax_parser::{parse_expression, parse_query}; + +#[test] +fn parse_expression_error_corpus() { + let cases = [ + ("unterminated string", r#""oops"#, "unterminated"), + ( + "invalid hierarchy tail", + "Table[Date].Year", + "hierarchy level", + ), + ]; + + for (name, input, expected) in cases { + let err = parse_expression(input).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(expected), + "{name} did not contain '{expected}': {msg}" + ); + } +} + +#[test] +fn parse_query_error_corpus() { + let cases = [( + "start at without order by", + "EVALUATE 't' START AT 1", + "START AT requires an ORDER BY", + )]; + + for (name, input, expected) in cases { + let err = parse_query(input).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains(expected), + "{name} did not contain '{expected}': {msg}" + ); + } +} diff --git a/crates/dax-parser/tests/corpus_functions.rs b/crates/dax-parser/tests/corpus_functions.rs new file mode 100644 index 00000000..8e581173 --- /dev/null +++ b/crates/dax-parser/tests/corpus_functions.rs @@ -0,0 +1,40 @@ +use dax_parser::{lex, parse_expression, Expr, TokenKind}; +use std::fs; +use std::path::PathBuf; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crate should be nested under repo root") + .to_path_buf() +} + +#[test] +fn keyword_functions_parse_as_calls() { + let path = repo_root().join("tests/dax/fixtures/tabulareditor/keyword_functions.txt"); + let text = fs::read_to_string(path).expect("keyword function fixture missing"); + + for kw in text.lines() { + let kw = kw.trim(); + if kw.is_empty() { + continue; + } + + let tokens = lex(kw).unwrap_or_else(|err| panic!("keyword lex failed: {kw}\n{err}")); + match &tokens[0].kind { + TokenKind::Ident(value) => assert_eq!(value, kw), + other => panic!("keyword did not lex as ident: {kw} -> {other:?}"), + } + + let expr = parse_expression(&format!("{kw}()")) + .unwrap_or_else(|err| panic!("keyword call parse failed: {kw}()\n{err}")); + match expr { + Expr::FunctionCall { name, args } => { + assert_eq!(name, kw); + assert!(args.is_empty()); + } + other => panic!("keyword did not parse as function call: {kw} -> {other:?}"), + } + } +} diff --git a/crates/dax-parser/tests/corpus_queries.rs b/crates/dax-parser/tests/corpus_queries.rs new file mode 100644 index 00000000..857ff227 --- /dev/null +++ b/crates/dax-parser/tests/corpus_queries.rs @@ -0,0 +1,59 @@ +use dax_parser::parse_query; +use std::fs; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crate should be nested under repo root") + .to_path_buf() +} + +fn load_blocks(path: &Path) -> Vec<(String, String)> { + let text = fs::read_to_string(path).expect("fixture file missing"); + let mut blocks = Vec::new(); + let mut current = Vec::new(); + + for line in text.lines() { + if line.trim() == "---" { + if !current.is_empty() { + blocks.push(current.join("\n")); + current.clear(); + } + continue; + } + current.push(line.to_string()); + } + if !current.is_empty() { + blocks.push(current.join("\n")); + } + + let mut out = Vec::new(); + for block in blocks { + let mut source = "".to_string(); + let mut expr_lines = Vec::new(); + for line in block.lines() { + if let Some(rest) = line.strip_prefix("# source:") { + source = rest.trim().to_string(); + continue; + } + expr_lines.push(line); + } + let expr = expr_lines.join("\n").trim().to_string(); + if expr.is_empty() { + continue; + } + out.push((source, expr)); + } + out +} + +#[test] +fn parse_query_corpus_query_docs() { + let path = repo_root().join("tests/dax/fixtures/query-docs/queries.txt"); + for (source, query) in load_blocks(&path) { + parse_query(&query) + .unwrap_or_else(|err| panic!("query-docs query failed: {source}\n{query}\n{err}")); + } +} diff --git a/crates/dax-pyo3/Cargo.toml b/crates/dax-pyo3/Cargo.toml new file mode 100644 index 00000000..57aa79db --- /dev/null +++ b/crates/dax-pyo3/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dax-pyo3" +version = "0.1.0" +edition = "2021" +description = "Python bindings for dax-parser" +license = "AGPL-3.0-only" +repository = "https://github.com/sidequery/sidemantic" +publish = false + +[lib] +name = "sidemantic_dax" +crate-type = ["cdylib"] + +[dependencies] +dax-parser = { path = "../dax-parser" } +pyo3 = { version = "0.21", features = ["extension-module", "abi3-py311"] } +pythonize = "0.21" +serde_json = "1.0" +serde = "1.0" diff --git a/crates/dax-pyo3/README.md b/crates/dax-pyo3/README.md new file mode 100644 index 00000000..c72214ed --- /dev/null +++ b/crates/dax-pyo3/README.md @@ -0,0 +1,34 @@ +# sidemantic-dax + +Python bindings for the `dax-parser` crate. + +## Build + +```bash +cd crates/dax-pyo3 +maturin develop +``` + +This package is built with ABI3 for Python 3.11+, so a single wheel works for 3.11–3.13. + +If your Python is newer than PyO3 supports (for example 3.14), set: + +```bash +export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 +``` + +## Usage + +```python +import sidemantic_dax as dax + +expr = dax.parse_expression("SUM('Sales'[Amount])") +query = dax.parse_query("evaluate 'Sales'") +``` + +`parse_expression` and `parse_query` return typed Python AST nodes. Raw JSON-style output is available as: + +```python +expr_raw = dax.parse_expression_raw("SUM('Sales'[Amount])") +tokens_raw = dax.lex_raw("SUM('Sales'[Amount])") +``` diff --git a/crates/dax-pyo3/pyproject.toml b/crates/dax-pyo3/pyproject.toml new file mode 100644 index 00000000..db24313b --- /dev/null +++ b/crates/dax-pyo3/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["maturin>=1.7"] +build-backend = "maturin" + +[project] +name = "sidemantic-dax" +version = "0.1.0" +description = "Python bindings for dax-parser" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "AGPL-3.0-only"} + +[tool.maturin] +module-name = "sidemantic_dax._native" +python-source = "python" +features = ["pyo3/extension-module", "pyo3/abi3-py311"] +exclude = ["dist/*", "target/*"] diff --git a/crates/dax-pyo3/python/sidemantic_dax/__init__.py b/crates/dax-pyo3/python/sidemantic_dax/__init__.py new file mode 100644 index 00000000..84d53398 --- /dev/null +++ b/crates/dax-pyo3/python/sidemantic_dax/__init__.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from typing import Any + +from . import ast as ast +from .ast import ( + Amp, + AndAnd, + Arrow, + Binary, + BinaryOp, + Blank, + Boolean, + BracketIdentToken, + BracketRef, + Caret, + Colon, + ColumnDef, + Comma, + DefineBlock, + Definition, + DocCommentToken, + Dot, + Eof, + Eq, + EqEq, + EvaluateStmt, + Expr, + FuncParam, + FunctionCall, + FunctionDef, + Gt, + Gte, + HierarchyRef, + Identifier, + IdentToken, + LBrace, + LParen, + Lt, + Lte, + MeasureDef, + Minus, + Neq, + Number, + NumberToken, + OrderKey, + OrOr, + Parameter, + ParamToken, + Paren, + Plus, + Query, + QuotedIdentToken, + RBrace, + RParen, + Semicolon, + Slash, + SortDirection, + Span, + Star, + String, + StringToken, + TableColumnRef, + TableConstructor, + TableDef, + TableName, + TableRef, + Token, + TokenKind, + Unary, + UnaryOp, + VarBlock, + VarDecl, + VarDef, + from_raw_expr, + from_raw_query, + from_raw_tokens, + lex, + parse_expression, + parse_query, +) + + +def parse_expression_raw(text: str) -> Any: + native = _native_module() + return native.parse_expression(text) + + +def parse_query_raw(text: str) -> Any: + native = _native_module() + return native.parse_query(text) + + +def lex_raw(text: str) -> Any: + native = _native_module() + return native.lex(text) + + +def _native_module(): + try: + from . import _native + except Exception as exc: # pragma: no cover - exercised via import in runtime + raise RuntimeError("sidemantic_dax native module is not available") from exc + return _native + + +__all__ = [ + "Amp", + "AndAnd", + "Arrow", + "Binary", + "BinaryOp", + "Blank", + "Boolean", + "BracketIdentToken", + "BracketRef", + "Caret", + "Colon", + "ColumnDef", + "Comma", + "DefineBlock", + "Definition", + "DocCommentToken", + "Dot", + "Eof", + "Eq", + "EqEq", + "EvaluateStmt", + "Expr", + "FuncParam", + "FunctionDef", + "FunctionCall", + "Gt", + "Gte", + "HierarchyRef", + "IdentToken", + "Identifier", + "LBrace", + "LParen", + "Lt", + "Lte", + "MeasureDef", + "Minus", + "Neq", + "Number", + "NumberToken", + "OrOr", + "OrderKey", + "Paren", + "Parameter", + "ParamToken", + "Plus", + "Query", + "QuotedIdentToken", + "RBrace", + "RParen", + "Semicolon", + "Slash", + "SortDirection", + "Span", + "Star", + "String", + "StringToken", + "TableColumnRef", + "TableConstructor", + "TableDef", + "TableName", + "TableRef", + "Token", + "TokenKind", + "Unary", + "UnaryOp", + "VarBlock", + "VarDecl", + "VarDef", + "ast", + "from_raw_expr", + "from_raw_query", + "from_raw_tokens", + "lex", + "lex_raw", + "parse_expression", + "parse_expression_raw", + "parse_query", + "parse_query_raw", +] diff --git a/crates/dax-pyo3/python/sidemantic_dax/ast.py b/crates/dax-pyo3/python/sidemantic_dax/ast.py new file mode 100644 index 00000000..43fab2b5 --- /dev/null +++ b/crates/dax-pyo3/python/sidemantic_dax/ast.py @@ -0,0 +1,776 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum +from typing import Any, TypeAlias + + +class UnaryOp(str, Enum): + plus = "Plus" + minus = "Minus" + not_ = "Not" + + +class BinaryOp(str, Enum): + or_ = "Or" + and_ = "And" + eq = "Eq" + strict_eq = "StrictEq" + neq = "Neq" + lt = "Lt" + lte = "Lte" + gt = "Gt" + gte = "Gte" + in_ = "In" + concat = "Concat" + add = "Add" + sub = "Sub" + mul = "Mul" + div = "Div" + pow = "Pow" + + +class SortDirection(str, Enum): + asc = "Asc" + desc = "Desc" + + +@dataclass(frozen=True, slots=True) +class Span: + start: int + end: int + + +@dataclass(frozen=True, slots=True) +class TableName: + name: str + quoted: bool + + +@dataclass(frozen=True, slots=True) +class VarDecl: + name: str + expr: Expr + + +@dataclass(frozen=True, slots=True) +class Number: + value: str + + +@dataclass(frozen=True, slots=True) +class String: + value: str + + +@dataclass(frozen=True, slots=True) +class Boolean: + value: bool + + +@dataclass(frozen=True, slots=True) +class Blank: + pass + + +@dataclass(frozen=True, slots=True) +class Parameter: + name: str + + +@dataclass(frozen=True, slots=True) +class Identifier: + name: str + + +@dataclass(frozen=True, slots=True) +class TableRef: + table: TableName + + +@dataclass(frozen=True, slots=True) +class BracketRef: + name: str + + +@dataclass(frozen=True, slots=True) +class TableColumnRef: + table: TableName + column: str + + +@dataclass(frozen=True, slots=True) +class HierarchyRef: + table: TableName + column: str + levels: list[str] + + +@dataclass(frozen=True, slots=True) +class FunctionCall: + name: str + args: list[Expr] + + +@dataclass(frozen=True, slots=True) +class Unary: + op: UnaryOp + expr: Expr + + +@dataclass(frozen=True, slots=True) +class Binary: + op: BinaryOp + left: Expr + right: Expr + + +@dataclass(frozen=True, slots=True) +class VarBlock: + decls: list[VarDecl] + body: Expr + + +@dataclass(frozen=True, slots=True) +class TableConstructor: + rows: list[list[Expr]] + + +@dataclass(frozen=True, slots=True) +class Paren: + expr: Expr + + +Expr: TypeAlias = ( + Number + | String + | Boolean + | Blank + | Parameter + | Identifier + | TableRef + | BracketRef + | TableColumnRef + | HierarchyRef + | FunctionCall + | Unary + | Binary + | VarBlock + | TableConstructor + | Paren +) + + +@dataclass(frozen=True, slots=True) +class MeasureDef: + doc: str | None + table: TableName | None + name: str + expr: Expr + + +@dataclass(frozen=True, slots=True) +class VarDef: + doc: str | None + name: str + expr: Expr + + +@dataclass(frozen=True, slots=True) +class TableDef: + doc: str | None + name: str + expr: Expr + + +@dataclass(frozen=True, slots=True) +class ColumnDef: + doc: str | None + table: TableName | None + name: str + expr: Expr + + +@dataclass(frozen=True, slots=True) +class FuncParam: + name: str + type_hints: list[str] + + +@dataclass(frozen=True, slots=True) +class FunctionDef: + doc: str | None + name: str + params: list[FuncParam] + body: Expr + + +Definition: TypeAlias = MeasureDef | VarDef | TableDef | ColumnDef | FunctionDef + + +@dataclass(frozen=True, slots=True) +class DefineBlock: + defs: list[Definition] + + +@dataclass(frozen=True, slots=True) +class OrderKey: + expr: Expr + direction: SortDirection + + +@dataclass(frozen=True, slots=True) +class EvaluateStmt: + expr: Expr + order_by: list[OrderKey] + start_at: list[Expr] | None + + +@dataclass(frozen=True, slots=True) +class Query: + define: DefineBlock | None + evaluates: list[EvaluateStmt] + + +@dataclass(frozen=True, slots=True) +class IdentToken: + value: str + + +@dataclass(frozen=True, slots=True) +class DocCommentToken: + value: str + + +@dataclass(frozen=True, slots=True) +class ParamToken: + value: str + + +@dataclass(frozen=True, slots=True) +class NumberToken: + value: str + + +@dataclass(frozen=True, slots=True) +class StringToken: + value: str + + +@dataclass(frozen=True, slots=True) +class QuotedIdentToken: + value: str + + +@dataclass(frozen=True, slots=True) +class BracketIdentToken: + value: str + + +@dataclass(frozen=True, slots=True) +class LParen: + pass + + +@dataclass(frozen=True, slots=True) +class RParen: + pass + + +@dataclass(frozen=True, slots=True) +class LBrace: + pass + + +@dataclass(frozen=True, slots=True) +class RBrace: + pass + + +@dataclass(frozen=True, slots=True) +class Comma: + pass + + +@dataclass(frozen=True, slots=True) +class Semicolon: + pass + + +@dataclass(frozen=True, slots=True) +class Colon: + pass + + +@dataclass(frozen=True, slots=True) +class Arrow: + pass + + +@dataclass(frozen=True, slots=True) +class Plus: + pass + + +@dataclass(frozen=True, slots=True) +class Minus: + pass + + +@dataclass(frozen=True, slots=True) +class Star: + pass + + +@dataclass(frozen=True, slots=True) +class Slash: + pass + + +@dataclass(frozen=True, slots=True) +class Caret: + pass + + +@dataclass(frozen=True, slots=True) +class Amp: + pass + + +@dataclass(frozen=True, slots=True) +class Eq: + pass + + +@dataclass(frozen=True, slots=True) +class EqEq: + pass + + +@dataclass(frozen=True, slots=True) +class Neq: + pass + + +@dataclass(frozen=True, slots=True) +class Lt: + pass + + +@dataclass(frozen=True, slots=True) +class Lte: + pass + + +@dataclass(frozen=True, slots=True) +class Gt: + pass + + +@dataclass(frozen=True, slots=True) +class Gte: + pass + + +@dataclass(frozen=True, slots=True) +class Dot: + pass + + +@dataclass(frozen=True, slots=True) +class AndAnd: + pass + + +@dataclass(frozen=True, slots=True) +class OrOr: + pass + + +@dataclass(frozen=True, slots=True) +class Eof: + pass + + +TokenKind: TypeAlias = ( + IdentToken + | DocCommentToken + | ParamToken + | NumberToken + | StringToken + | QuotedIdentToken + | BracketIdentToken + | LParen + | RParen + | LBrace + | RBrace + | Comma + | Semicolon + | Colon + | Arrow + | Plus + | Minus + | Star + | Slash + | Caret + | Amp + | Eq + | EqEq + | Neq + | Lt + | Lte + | Gt + | Gte + | Dot + | AndAnd + | OrOr + | Eof +) + + +@dataclass(frozen=True, slots=True) +class Token: + kind: TokenKind + span: Span + + +def parse_expression(text: str) -> Expr: + raw = _native_parse_expression(text) + return from_raw_expr(raw) + + +def parse_query(text: str) -> Query: + raw = _native_parse_query(text) + return from_raw_query(raw) + + +def lex(text: str) -> list[Token]: + raw = _native_lex(text) + return from_raw_tokens(raw) + + +def from_raw_expr(raw: Any) -> Expr: + if isinstance(raw, str): + if raw == "Blank": + return Blank() + raise ValueError(f"Unexpected expr variant: {raw}") + if not isinstance(raw, dict) or len(raw) != 1: + raise ValueError(f"Invalid expr payload: {raw!r}") + + key, value = next(iter(raw.items())) + if key == "Number": + return Number(value=value) + if key == "String": + return String(value=value) + if key == "Boolean": + return Boolean(value=bool(value)) + if key == "Blank": + return Blank() + if key == "Parameter": + return Parameter(name=value) + if key == "Identifier": + return Identifier(name=value) + if key == "TableRef": + return TableRef(table=_from_raw_table_name(value)) + if key == "BracketRef": + return BracketRef(name=value) + if key == "TableColumnRef": + return TableColumnRef(table=_from_raw_table_name(value["table"]), column=value["column"]) + if key == "HierarchyRef": + return HierarchyRef( + table=_from_raw_table_name(value["table"]), + column=value["column"], + levels=list(value.get("levels", [])), + ) + if key == "FunctionCall": + return FunctionCall(name=value["name"], args=[from_raw_expr(arg) for arg in value["args"]]) + if key == "Unary": + return Unary(op=_to_unary_op(value["op"]), expr=from_raw_expr(value["expr"])) + if key == "Binary": + return Binary( + op=_to_binary_op(value["op"]), + left=from_raw_expr(value["left"]), + right=from_raw_expr(value["right"]), + ) + if key == "VarBlock": + return VarBlock( + decls=[_from_raw_var_decl(decl) for decl in value["decls"]], + body=from_raw_expr(value["body"]), + ) + if key == "TableConstructor": + return TableConstructor(rows=[[from_raw_expr(expr) for expr in row] for row in value]) + if key == "Paren": + return Paren(expr=from_raw_expr(value)) + raise ValueError(f"Unknown expr variant: {key}") + + +def from_raw_query(raw: Any) -> Query: + if not isinstance(raw, dict): + raise ValueError(f"Invalid query payload: {raw!r}") + define_raw = raw.get("define") + defines = _from_raw_define_block(define_raw) if define_raw is not None else None + evaluates = [_from_raw_evaluate(stmt) for stmt in raw.get("evaluates", [])] + return Query(define=defines, evaluates=evaluates) + + +def from_raw_tokens(raw: Any) -> list[Token]: + if not isinstance(raw, Iterable): + raise ValueError(f"Invalid token list: {raw!r}") + return [_from_raw_token(token) for token in raw] + + +def _native_parse_expression(text: str) -> Any: + native = _native_module() + return native.parse_expression(text) + + +def _native_parse_query(text: str) -> Any: + native = _native_module() + return native.parse_query(text) + + +def _native_lex(text: str) -> Any: + native = _native_module() + return native.lex(text) + + +def _native_module(): + try: + from . import _native + except Exception as exc: # pragma: no cover - exercised via import in runtime + raise RuntimeError("sidemantic_dax native module is not available") from exc + return _native + + +def _from_raw_table_name(raw: Any) -> TableName: + if not isinstance(raw, dict): + raise ValueError(f"Invalid table name payload: {raw!r}") + return TableName(name=raw["name"], quoted=bool(raw["quoted"])) + + +def _from_raw_var_decl(raw: Any) -> VarDecl: + if not isinstance(raw, dict): + raise ValueError(f"Invalid var decl payload: {raw!r}") + return VarDecl(name=raw["name"], expr=from_raw_expr(raw["expr"])) + + +def _from_raw_func_param(raw: Any) -> FuncParam: + if not isinstance(raw, dict): + raise ValueError(f"Invalid func param payload: {raw!r}") + return FuncParam(name=raw["name"], type_hints=list(raw.get("type_hints", []))) + + +def _from_raw_define_block(raw: Any) -> DefineBlock: + if not isinstance(raw, dict): + raise ValueError(f"Invalid define block payload: {raw!r}") + return DefineBlock(defs=[_from_raw_definition(defn) for defn in raw.get("defs", [])]) + + +def _from_raw_definition(raw: Any) -> Definition: + if not isinstance(raw, dict) or len(raw) != 1: + raise ValueError(f"Invalid definition payload: {raw!r}") + key, value = next(iter(raw.items())) + if key == "Measure": + table = _from_raw_table_name(value["table"]) if value.get("table") is not None else None + return MeasureDef(doc=value.get("doc"), table=table, name=value["name"], expr=from_raw_expr(value["expr"])) + if key == "Var": + return VarDef(doc=value.get("doc"), name=value["name"], expr=from_raw_expr(value["expr"])) + if key == "Table": + return TableDef(doc=value.get("doc"), name=value["name"], expr=from_raw_expr(value["expr"])) + if key == "Column": + table = _from_raw_table_name(value["table"]) if value.get("table") is not None else None + return ColumnDef( + doc=value.get("doc"), + table=table, + name=value["name"], + expr=from_raw_expr(value["expr"]), + ) + if key == "Function": + params = [_from_raw_func_param(param) for param in value.get("params", [])] + return FunctionDef( + doc=value.get("doc"), + name=value["name"], + params=params, + body=from_raw_expr(value["body"]), + ) + raise ValueError(f"Unknown definition variant: {key}") + + +def _from_raw_evaluate(raw: Any) -> EvaluateStmt: + if not isinstance(raw, dict): + raise ValueError(f"Invalid evaluate payload: {raw!r}") + order_by = [_from_raw_order_key(key) for key in raw.get("order_by", [])] + start_at = raw.get("start_at") + parsed_start_at = [from_raw_expr(expr) for expr in start_at] if start_at is not None else None + return EvaluateStmt(expr=from_raw_expr(raw["expr"]), order_by=order_by, start_at=parsed_start_at) + + +def _from_raw_order_key(raw: Any) -> OrderKey: + if not isinstance(raw, dict): + raise ValueError(f"Invalid order key payload: {raw!r}") + return OrderKey(expr=from_raw_expr(raw["expr"]), direction=_to_sort_direction(raw["direction"])) + + +def _from_raw_token(raw: Any) -> Token: + if not isinstance(raw, dict): + raise ValueError(f"Invalid token payload: {raw!r}") + return Token(kind=_from_raw_token_kind(raw["kind"]), span=_from_raw_span(raw["span"])) + + +def _from_raw_span(raw: Any) -> Span: + if not isinstance(raw, dict): + raise ValueError(f"Invalid span payload: {raw!r}") + return Span(start=int(raw["start"]), end=int(raw["end"])) + + +def _from_raw_token_kind(raw: Any) -> TokenKind: + if isinstance(raw, str): + return _unit_token_kind(raw) + if not isinstance(raw, dict) or len(raw) != 1: + raise ValueError(f"Invalid token kind payload: {raw!r}") + key, value = next(iter(raw.items())) + if key == "DocComment": + return DocCommentToken(value=value) + if key == "Param": + return ParamToken(value=value) + if key == "Ident": + return IdentToken(value=value) + if key == "Number": + return NumberToken(value=value) + if key == "String": + return StringToken(value=value) + if key == "QuotedIdent": + return QuotedIdentToken(value=value) + if key == "BracketIdent": + return BracketIdentToken(value=value) + return _unit_token_kind(key) + + +def _unit_token_kind(name: str) -> TokenKind: + mapping: dict[str, TokenKind] = { + "LParen": LParen(), + "RParen": RParen(), + "LBrace": LBrace(), + "RBrace": RBrace(), + "Comma": Comma(), + "Semicolon": Semicolon(), + "Colon": Colon(), + "Arrow": Arrow(), + "Plus": Plus(), + "Minus": Minus(), + "Star": Star(), + "Slash": Slash(), + "Caret": Caret(), + "Amp": Amp(), + "Eq": Eq(), + "EqEq": EqEq(), + "Neq": Neq(), + "Lt": Lt(), + "Lte": Lte(), + "Gt": Gt(), + "Gte": Gte(), + "Dot": Dot(), + "AndAnd": AndAnd(), + "OrOr": OrOr(), + "Eof": Eof(), + } + if name in mapping: + return mapping[name] + raise ValueError(f"Unknown token kind: {name}") + + +def _to_unary_op(raw: Any) -> UnaryOp: + if isinstance(raw, UnaryOp): + return raw + return UnaryOp(raw) + + +def _to_binary_op(raw: Any) -> BinaryOp: + if isinstance(raw, BinaryOp): + return raw + return BinaryOp(raw) + + +def _to_sort_direction(raw: Any) -> SortDirection: + if isinstance(raw, SortDirection): + return raw + return SortDirection(raw) + + +__all__ = [ + "Amp", + "AndAnd", + "Arrow", + "Binary", + "BinaryOp", + "Blank", + "Boolean", + "BracketIdentToken", + "BracketRef", + "Caret", + "Colon", + "ColumnDef", + "Comma", + "DefineBlock", + "Definition", + "DocCommentToken", + "Dot", + "Eof", + "Eq", + "EqEq", + "EvaluateStmt", + "Expr", + "FuncParam", + "FunctionDef", + "FunctionCall", + "Gt", + "Gte", + "HierarchyRef", + "IdentToken", + "Identifier", + "LBrace", + "LParen", + "Lt", + "Lte", + "MeasureDef", + "Minus", + "Neq", + "Number", + "NumberToken", + "OrOr", + "OrderKey", + "Paren", + "Parameter", + "ParamToken", + "Plus", + "Query", + "QuotedIdentToken", + "RBrace", + "RParen", + "Semicolon", + "Slash", + "SortDirection", + "Span", + "Star", + "String", + "StringToken", + "TableColumnRef", + "TableConstructor", + "TableDef", + "TableName", + "TableRef", + "Token", + "TokenKind", + "Unary", + "UnaryOp", + "VarBlock", + "VarDecl", + "VarDef", + "from_raw_expr", + "from_raw_query", + "from_raw_tokens", + "lex", + "parse_expression", + "parse_query", +] diff --git a/crates/dax-pyo3/python/sidemantic_dax/py.typed b/crates/dax-pyo3/python/sidemantic_dax/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/crates/dax-pyo3/src/lib.rs b/crates/dax-pyo3/src/lib.rs new file mode 100644 index 00000000..1ae8d84a --- /dev/null +++ b/crates/dax-pyo3/src/lib.rs @@ -0,0 +1,36 @@ +use dax_parser::{lex, parse_expression, parse_query}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pythonize::pythonize; +use serde_json::to_value; + +fn to_py_object(py: Python<'_>, value: impl serde::Serialize) -> PyResult { + let json = to_value(value).map_err(|err| PyValueError::new_err(err.to_string()))?; + pythonize(py, &json).map_err(|err| PyValueError::new_err(err.to_string())) +} + +#[pyfunction(name = "parse_expression")] +fn parse_expression_py(py: Python<'_>, input: &str) -> PyResult { + let expr = parse_expression(input).map_err(|err| PyValueError::new_err(err.to_string()))?; + to_py_object(py, expr) +} + +#[pyfunction(name = "parse_query")] +fn parse_query_py(py: Python<'_>, input: &str) -> PyResult { + let query = parse_query(input).map_err(|err| PyValueError::new_err(err.to_string()))?; + to_py_object(py, query) +} + +#[pyfunction(name = "lex")] +fn lex_py(py: Python<'_>, input: &str) -> PyResult { + let tokens = lex(input).map_err(|err| PyValueError::new_err(err.to_string()))?; + to_py_object(py, tokens) +} + +#[pymodule] +fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(parse_expression_py, m)?)?; + m.add_function(wrap_pyfunction!(parse_query_py, m)?)?; + m.add_function(wrap_pyfunction!(lex_py, m)?)?; + Ok(()) +} diff --git a/pyproject.toml b/pyproject.toml index 3bfffbb5..068d0a5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,9 @@ lsp = [ "lsprotocol>=2025.0.0", "pygls>=2.0.0", ] +dax = [ + "sidemantic-dax>=0.1.0", +] lookml = [ "lkml>=1.3.7", ] @@ -111,7 +114,7 @@ all-databases = [ "sidemantic[postgres,bigquery,snowflake,clickhouse,databricks,spark,adbc]", ] full = [ - "sidemantic[workbench,mcp,apps,charts,lsp,lookml,malloy,metricflow,widget,api]", + "sidemantic[workbench,mcp,apps,charts,lsp,dax,lookml,malloy,metricflow,widget,api]", ] [build-system] @@ -121,6 +124,16 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.sdist] exclude = [ "/.claude", + "/.git", + "/.github", + "/.mypy_cache", + "/.pytest_cache", + "/.ruff_cache", + "/.venv", + "/crates/dax-pyo3/dist", + "/crates/dax-pyo3/target", + "/crates/dax-parser/target", + "/dist", "/docs", "/tests", "/examples/__pycache__", @@ -133,6 +146,13 @@ exclude = [ "/examples/sidemantic", "/examples/sql", "/examples/superset", + "/sidemantic-duckdb", + "/sidemantic-rs", + "/skills", + "/target", + "/vscode-sidemantic", + "**/__pycache__", + "*.pyc", "*.pkg", "test_*.py", "uv.lock", @@ -178,6 +198,9 @@ source = ["sidemantic"] [tool.uv] prerelease = "if-necessary" +[tool.uv.sources] +sidemantic-dax = { path = "crates/dax-pyo3" } + [dependency-groups] dev = [ "antlr4-python3-runtime>=4.13.2", diff --git a/sidemantic-rs/Cargo.toml b/sidemantic-rs/Cargo.toml index bd653ec2..97a064bb 100644 --- a/sidemantic-rs/Cargo.toml +++ b/sidemantic-rs/Cargo.toml @@ -8,7 +8,7 @@ description = "A SQL-first semantic layer in Rust" crate-type = ["rlib", "staticlib"] [dependencies] -polyglot-sql = "0.1" +polyglot-sql = "0.3.9" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" diff --git a/sidemantic-rs/src/sql/rewriter.rs b/sidemantic-rs/src/sql/rewriter.rs index cf2b2165..d5a1acc7 100644 --- a/sidemantic-rs/src/sql/rewriter.rs +++ b/sidemantic-rs/src/sql/rewriter.rs @@ -198,8 +198,11 @@ impl<'a> QueryRewriter<'a> { this: rewritten_inner, alias: alias.alias.clone(), column_aliases: alias.column_aliases.clone(), + alias_explicit_as: alias.alias_explicit_as, + alias_keyword: alias.alias_keyword.clone(), pre_alias_comments: alias.pre_alias_comments.clone(), trailing_comments: alias.trailing_comments.clone(), + inferred_type: alias.inferred_type.clone(), }))); } Expression::Star(_) => result.push(item.clone()), @@ -275,6 +278,7 @@ impl<'a> QueryRewriter<'a> { filter: None, ignore_nulls: None, original_name: None, + inferred_type: None, })); } @@ -289,6 +293,7 @@ impl<'a> QueryRewriter<'a> { ignore_nulls: None, having_max: None, limit: None, + inferred_type: None, }; match agg { @@ -300,6 +305,7 @@ impl<'a> QueryRewriter<'a> { filter: None, ignore_nulls: None, original_name: None, + inferred_type: None, })), crate::core::Aggregation::CountDistinct => { Expression::Count(Box::new(CountFunc { @@ -309,6 +315,7 @@ impl<'a> QueryRewriter<'a> { filter: None, ignore_nulls: None, original_name: None, + inferred_type: None, })) } crate::core::Aggregation::Avg => Expression::Avg(Box::new(make_agg(col_expr))), @@ -423,7 +430,7 @@ impl<'a> QueryRewriter<'a> { ); new_joins.push(Join { - this: Expression::Table(join_table), + this: Expression::Table(Box::new(join_table)), on: Some(join_condition), using: vec![], kind: JoinKind::Left, @@ -445,7 +452,7 @@ impl<'a> QueryRewriter<'a> { let mut new_table = make_table_ref(model.table_name()); new_table.alias = table_ref.alias.clone(); new_table.alias_explicit_as = table_ref.alias_explicit_as; - new_from_exprs.push(Expression::Table(new_table)); + new_from_exprs.push(Expression::Table(Box::new(new_table))); } else { new_from_exprs.push(expr.clone()); } @@ -487,6 +494,7 @@ impl<'a> QueryRewriter<'a> { left_comments: vec![], operator_comments: vec![], trailing_comments: vec![], + inferred_type: None, })) } @@ -576,7 +584,9 @@ impl<'a> QueryRewriter<'a> { }; if !self.is_aggregation(expr) { // Use positional reference - group_by_exprs.push(Expression::Literal(Literal::Number((i + 1).to_string()))); + group_by_exprs.push(Expression::Literal(Box::new(Literal::Number( + (i + 1).to_string(), + )))); } } diff --git a/sidemantic-schema.json b/sidemantic-schema.json index 7d221a51..1d277a19 100644 --- a/sidemantic-schema.json +++ b/sidemantic-schema.json @@ -1,1918 +1,1203 @@ { - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Unique dimension name within model", - "title": "Name", - "type": "string" - }, - "parent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" - }, - "supported_granularities": { - "anyOf": [ - { - "items": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Sidemantic Semantic Layer", + "description": "Schema for Sidemantic semantic layer YAML configuration", + "type": "object", + "properties": { + "models": { + "type": "array", + "description": "Model definitions", + "items": { + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "name": { + "description": "Unique dimension name within model", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" - }, - "type": { - "description": "Dimension type", - "enum": [ - "categorical", - "time", - "boolean", - "numeric" - ], - "title": "Type", - "type": "string" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" - } - }, - "required": [ - "name", - "type" - ], - "title": "Dimension", - "type": "object" - }, - "Index": { - "description": "Index definition for pre-aggregation performance.", - "properties": { - "columns": { - "description": "Columns to index", - "items": { - "type": "string" - }, - "title": "Columns", - "type": "array" - }, - "name": { - "description": "Index name", - "title": "Name", - "type": "string" - }, - "type": { - "default": "regular", - "description": "Index type", - "enum": [ - "regular", - "aggregate" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name", - "columns" - ], - "title": "Index", - "type": "object" - }, - "Metric": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "activity_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "base_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "cohort_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "drill_fields": { - "anyOf": [ - { - "items": { + "type": { + "description": "Dimension type", + "enum": [ + "categorical", + "time", + "boolean", + "numeric" + ], + "title": "Type", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "having": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" - }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" - }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" - }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" - }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - }, - "Parameter": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" - }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" - }, - "PreAggregation": { - "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", - "properties": { - "build_range_end": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for end of data range to aggregate", - "title": "Build Range End" - }, - "build_range_start": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for start of data range to aggregate", - "title": "Build Range Start" - }, - "dimensions": { - "anyOf": [ - { - "items": { - "type": "string" + "supported_granularities": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to group by (e.g., ['status', 'region'])", - "title": "Dimensions" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for aggregation", - "title": "Granularity" - }, - "indexes": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Index" + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Index definitions for query performance", - "title": "Indexes" - }, - "measures": { - "anyOf": [ - { - "items": { - "type": "string" + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", - "title": "Measures" - }, - "name": { - "description": "Unique pre-aggregation name", - "title": "Name", - "type": "string" - }, - "partition_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Partition size for incremental refresh", - "title": "Partition Granularity" - }, - "refresh_key": { - "anyOf": [ - { - "$ref": "#/$defs/RefreshKey" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh strategy configuration" - }, - "scheduled_refresh": { - "default": true, - "description": "Whether to enable scheduled refresh", - "title": "Scheduled Refresh", - "type": "boolean" - }, - "time_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time dimension for temporal grouping", - "title": "Time Dimension" - }, - "type": { - "default": "rollup", - "description": "Pre-aggregation type", - "enum": [ - "rollup", - "original_sql", - "rollup_join", - "lambda" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name" - ], - "title": "PreAggregation", - "type": "object" - }, - "RefreshKey": { - "description": "Refresh strategy configuration for pre-aggregations.", - "properties": { - "every": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", - "title": "Every" - }, - "incremental": { - "default": false, - "description": "Whether to use incremental refresh (only update changed partitions)", - "title": "Incremental", - "type": "boolean" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL query that returns a value to trigger refresh when changed", - "title": "Sql" - }, - "update_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", - "title": "Update Window" - } - }, - "title": "RefreshKey", - "type": "object" - }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "foreign_key": { - "anyOf": [ - { - "type": "string" + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" + }, + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" + } }, - { - "items": { + "required": [ + "name", + "type" + ], + "title": "Dimension", + "type": "object" + }, + "Index": { + "description": "Index definition for pre-aggregation performance.", + "properties": { + "name": { + "description": "Index name", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Name of the related model", - "title": "Name", - "type": "string" - }, - "primary_key": { - "anyOf": [ - { - "type": "string" + "columns": { + "description": "Columns to index", + "items": { + "type": "string" + }, + "title": "Columns", + "type": "array" + }, + "type": { + "default": "regular", + "description": "Index type", + "enum": [ + "regular", + "aggregate" + ], + "title": "Type", + "type": "string" + } }, - { - "items": { + "required": [ + "name", + "columns" + ], + "title": "Index", + "type": "object" + }, + "Metric": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique measure name", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "type": { - "description": "Type of relationship", - "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" - }, - "Segment": { - "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "name": { - "description": "Unique segment name", - "title": "Name", - "type": "string" - }, - "public": { - "default": true, - "description": "Whether segment is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "sql": { - "description": "SQL WHERE clause expression", - "title": "Sql", - "type": "string" - } - }, - "required": [ - "name", - "sql" - ], - "title": "Segment", - "type": "object" - } - }, - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Schema for Sidemantic semantic layer YAML configuration", - "properties": { - "metrics": { - "description": "Top-level metric definitions (optional - can also define in models)", - "items": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "activity_event": { - "anyOf": [ - { - "type": "string" + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "relationship_overrides": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/RelationshipOverride" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Relationship overrides for this metric", + "title": "Relationship Overrides" + }, + "required_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Additional models required for this metric", + "title": "Required Models" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" + "window_expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "base_event": { - "anyOf": [ - { - "type": "string" + "window_frame": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio" + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio", + "previous_value" + ], + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "cohort_event": { - "anyOf": [ - { - "type": "string" + "entity": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" + "base_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Starting event filter", + "title": "Base Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "denominator": { - "anyOf": [ - { - "type": "string" + "steps": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "description": { - "anyOf": [ - { - "type": "string" + "cohort_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "entity": { - "anyOf": [ - { - "type": "string" + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "extends": { - "anyOf": [ - { - "type": "string" + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" }, - { - "type": "number" + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" }, - { - "type": "string" + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "format": { - "anyOf": [ - { - "type": "string" + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "having": { - "anyOf": [ - { - "type": "string" + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" - }, - "label": { - "anyOf": [ - { - "type": "string" + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" }, - { - "type": "null" + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" } + }, + "required": [ + "name" ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" + "title": "Metric", + "type": "object" }, - "non_additive_dimension": { - "anyOf": [ - { + "PreAggregation": { + "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", + "properties": { + "name": { + "description": "Unique pre-aggregation name", + "title": "Name", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" - }, - "numerator": { - "anyOf": [ - { + "type": { + "default": "rollup", + "description": "Pre-aggregation type", + "enum": [ + "rollup", + "original_sql", + "rollup_join", + "lambda" + ], + "title": "Type", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "offset_window": { - "anyOf": [ - { - "type": "string" + "measures": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", + "title": "Measures" }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" - }, - "periods": { - "anyOf": [ - { - "type": "integer" + "dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to group by (e.g., ['status', 'region'])", + "title": "Dimensions" }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" + "time_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Time dimension for temporal grouping", + "title": "Time Dimension" }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "sql": { - "anyOf": [ - { - "type": "string" + "granularity": { + "anyOf": [ + { + "enum": [ + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for aggregation", + "title": "Granularity" + }, + "partition_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Partition size for incremental refresh", + "title": "Partition Granularity" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "refresh_key": { + "anyOf": [ + { + "$ref": "#/$defs/RefreshKey" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh strategy configuration" }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" + "scheduled_refresh": { + "default": true, + "description": "Whether to enable scheduled refresh", + "title": "Scheduled Refresh", + "type": "boolean" }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" + "indexes": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Index" + }, + "type": "array" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Index definitions for query performance", + "title": "Indexes" }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" + "build_range_start": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for start of data range to aggregate", + "title": "Build Range Start" }, - { - "type": "null" + "build_range_end": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for end of data range to aggregate", + "title": "Build Range End" } + }, + "required": [ + "name" ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" + "title": "PreAggregation", + "type": "object" }, - "window": { - "anyOf": [ - { - "type": "string" + "RefreshKey": { + "description": "Refresh strategy configuration for pre-aggregations.", + "properties": { + "every": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", + "title": "Every" }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL query that returns a value to trigger refresh when changed", + "title": "Sql" }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" + "incremental": { + "default": false, + "description": "Whether to use incremental refresh (only update changed partitions)", + "title": "Incremental", + "type": "boolean" }, - { - "type": "null" + "update_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", + "title": "Update Window" } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" + }, + "title": "RefreshKey", + "type": "object" }, - "window_order": { - "anyOf": [ - { + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", + "properties": { + "name": { + "description": "Name of the related model", + "title": "Name", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - }, - "type": "array" - }, - "models": { - "description": "Model definitions", - "items": { - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "description": { + "type": { + "description": "Type of relationship", + "enum": [ + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" + ], + "title": "Type", + "type": "string" + }, + "foreign_key": { "anyOf": [ { "type": "string" }, + { + "items": { + "type": "string" + }, + "type": "array" + }, { "type": "null" } ], "default": null, - "description": "Human-readable description", - "title": "Description" + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" }, - "format": { + "primary_key": { "anyOf": [ { "type": "string" }, + { + "items": { + "type": "string" + }, + "type": "array" + }, { "type": "null" } ], "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" }, - "granularity": { + "through": { "anyOf": [ { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], "type": "string" }, { @@ -1920,10 +1205,10 @@ } ], "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" + "description": "Junction model for many_to_many relationships", + "title": "Through" }, - "label": { + "through_foreign_key": { "anyOf": [ { "type": "string" @@ -1933,22 +1218,27 @@ } ], "default": null, - "description": "Display label", - "title": "Label" + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" }, - "meta": { + "related_foreign_key": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "type": "string" }, { "type": "null" } ], "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" + }, + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" }, "metadata": { "anyOf": [ @@ -1963,15 +1253,47 @@ "default": null, "description": "Adapter-specific metadata payload", "title": "Metadata" + } + }, + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "RelationshipOverride": { + "description": "Overrides join behavior for a relationship in a metric/query context.", + "properties": { + "from_model": { + "description": "Source model name", + "title": "From Model", + "type": "string" }, - "name": { - "description": "Unique dimension name within model", - "title": "Name", + "from_column": { + "description": "Source model column", + "title": "From Column", "type": "string" }, - "parent": { + "to_model": { + "description": "Target model name", + "title": "To Model", + "type": "string" + }, + "to_column": { + "description": "Target model column", + "title": "To Column", + "type": "string" + }, + "join_type": { "anyOf": [ { + "enum": [ + "inner", + "left", + "right", + "full" + ], "type": "string" }, { @@ -1979,16 +1301,10 @@ } ], "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" + "description": "Optional join type override", + "title": "Join Type" }, - "sql": { + "direction": { "anyOf": [ { "type": "string" @@ -1998,213 +1314,351 @@ } ], "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" + "description": "Optional filter direction hint", + "title": "Direction" + } + }, + "required": [ + "from_model", + "from_column", + "to_model", + "to_column" + ], + "title": "RelationshipOverride", + "type": "object" + }, + "Segment": { + "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", + "properties": { + "name": { + "description": "Unique segment name", + "title": "Name", + "type": "string" }, - "supported_granularities": { + "sql": { + "description": "SQL WHERE clause expression", + "title": "Sql", + "type": "string" + }, + "description": { "anyOf": [ { - "items": { - "type": "string" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" + "description": "Human-readable description", + "title": "Description" }, - "type": { - "description": "Dimension type", + "public": { + "default": true, + "description": "Whether segment is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name", + "sql" + ], + "title": "Segment", + "type": "object" + } + }, + "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique model name", + "title": "Name", + "type": "string" + }, + "table": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Physical table name (schema.table)", + "title": "Table" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for derived tables", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX table expression source text to lower into SQL", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { "enum": [ - "categorical", - "time", - "boolean", - "numeric" + "sql", + "dax" ], - "title": "Type", "type": "string" }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/dax derived table authoring", + "title": "Expression Language" + }, + "source_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Remote data source URI (e.g., https://, s3://, gs://)", + "title": "Source Uri" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent model to inherit from", + "title": "Extends" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "relationships": { + "description": "Relationships to other models", + "items": { + "$ref": "#/$defs/Relationship" + }, + "title": "Relationships", + "type": "array" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" } - }, - "required": [ - "name", - "type" ], - "title": "Dimension", - "type": "object" + "default": "id", + "description": "Primary key column(s)", + "title": "Primary Key" }, - "Index": { - "description": "Index definition for pre-aggregation performance.", - "properties": { - "columns": { - "description": "Columns to index", + "unique_keys": { + "anyOf": [ + { "items": { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, - "title": "Columns", "type": "array" }, - "name": { - "description": "Index name", - "title": "Name", + { + "type": "null" + } + ], + "default": null, + "description": "Unique key constraints (each is a list of columns)", + "title": "Unique Keys" + }, + "dimensions": { + "description": "Dimension definitions", + "items": { + "$ref": "#/$defs/Dimension" + }, + "title": "Dimensions", + "type": "array" + }, + "metrics": { + "description": "Measure definitions", + "items": { + "$ref": "#/$defs/Metric" + }, + "title": "Metrics", + "type": "array" + }, + "segments": { + "description": "Segment (named filter) definitions", + "items": { + "$ref": "#/$defs/Segment" + }, + "title": "Segments", + "type": "array" + }, + "pre_aggregations": { + "description": "Pre-aggregation definitions for query optimization", + "items": { + "$ref": "#/$defs/PreAggregation" + }, + "title": "Pre Aggregations", + "type": "array" + }, + "default_time_dimension": { + "anyOf": [ + { "type": "string" }, - "type": { - "default": "regular", - "description": "Index type", + { + "type": "null" + } + ], + "default": null, + "description": "Default time dimension for metrics (auto-included in queries)", + "title": "Default Time Dimension" + }, + "default_grain": { + "anyOf": [ + { "enum": [ - "regular", - "aggregate" + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" ], - "title": "Type", "type": "string" + }, + { + "type": "null" } - }, - "required": [ - "name", - "columns" ], - "title": "Index", - "type": "object" + "default": null, + "description": "Default time granularity when using default_time_dimension", + "title": "Default Grain" }, - "Metric": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "activity_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "base_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" + "auto_dimensions": { + "default": false, + "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", + "title": "Auto Dimensions", + "type": "boolean" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + } + }, + "required": [ + "name" + ], + "title": "Model", + "type": "object" + } + }, + "metrics": { + "type": "array", + "description": "Top-level metric definitions (optional - can also define in models)", + "items": { + "$defs": { + "RelationshipOverride": { + "description": "Overrides join behavior for a relationship in a metric/query context.", + "properties": { + "from_model": { + "description": "Source model name", + "title": "From Model", + "type": "string" }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" + "from_column": { + "description": "Source model column", + "title": "From Column", + "type": "string" }, - "cohort_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" + "to_model": { + "description": "Target model name", + "title": "To Model", + "type": "string" }, - "comparison_type": { + "to_column": { + "description": "Target model column", + "title": "To Column", + "type": "string" + }, + "join_type": { "anyOf": [ { "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" + "inner", + "left", + "right", + "full" ], "type": "string" }, @@ -2213,10 +1667,10 @@ } ], "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" + "description": "Optional join type override", + "title": "Join Type" }, - "conversion_event": { + "direction": { "anyOf": [ { "type": "string" @@ -2226,1159 +1680,1896 @@ } ], "default": null, - "description": "Target event filter", - "title": "Conversion Event" + "description": "Optional filter direction hint", + "title": "Direction" + } + }, + "required": [ + "from_model", + "from_column", + "to_model", + "to_column" + ], + "title": "RelationshipOverride", + "type": "object" + } + }, + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "extends": { + "anyOf": [ + { + "type": "string" }, - "conversion_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" + "type": "string" }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" + "type": "string" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "relationship_overrides": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/RelationshipOverride" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Relationship overrides for this metric", + "title": "Relationship Overrides" + }, + "required_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Additional models required for this metric", + "title": "Required Models" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" ], - "default": null, - "description": "Human-readable description", - "title": "Description" + "type": "string" }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" + "type": "string" }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "window_expression": { + "anyOf": [ + { + "type": "string" }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" + }, + "window_frame": { + "anyOf": [ + { + "type": "string" }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" + }, + "window_order": { + "anyOf": [ + { + "type": "string" }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" + "type": "string" }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio", + "previous_value" ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" + "type": "string" }, - "having": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "entity": { + "anyOf": [ + { + "type": "string" }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "base_event": { + "anyOf": [ + { + "type": "string" }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "conversion_event": { + "anyOf": [ + { + "type": "string" }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "steps": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "name": { - "description": "Unique measure name", - "title": "Name", + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" + }, + "cohort_event": { + "anyOf": [ + { "type": "string" }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "activity_event": { + "anyOf": [ + { + "type": "string" }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "periods": { + "anyOf": [ + { + "type": "integer" }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" + "type": "string" }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" + }, + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" + { + "type": "number" }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" + { + "type": "string" }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "format": { + "anyOf": [ + { + "type": "string" }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" + }, + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + } + }, + "parameters": { + "type": "array", + "description": "Parameter definitions for dynamic queries", + "items": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" + { + "type": "null" } - }, - "required": [ - "name" ], - "title": "Metric", - "type": "object" + "default": null, + "description": "Human-readable description", + "title": "Description" }, - "PreAggregation": { - "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", - "properties": { - "build_range_end": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for end of data range to aggregate", - "title": "Build Range End" - }, - "build_range_start": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for start of data range to aggregate", - "title": "Build Range Start" - }, - "dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to group by (e.g., ['status', 'region'])", - "title": "Dimensions" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for aggregation", - "title": "Granularity" - }, - "indexes": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Index" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Index definitions for query performance", - "title": "Indexes" + "label": { + "anyOf": [ + { + "type": "string" }, - "measures": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", - "title": "Measures" + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" }, - "name": { - "description": "Unique pre-aggregation name", - "title": "Name", + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + } + }, + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" + } + } + }, + "required": [ + "models" + ], + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "name": { + "description": "Unique dimension name within model", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Dimension type", + "enum": [ + "categorical", + "time", + "boolean", + "numeric" + ], + "title": "Type", + "type": "string" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" + }, + "supported_granularities": { + "anyOf": [ + { + "items": { "type": "string" }, - "partition_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Partition size for incremental refresh", - "title": "Partition Granularity" - }, - "refresh_key": { - "anyOf": [ - { - "$ref": "#/$defs/RefreshKey" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh strategy configuration" - }, - "scheduled_refresh": { - "default": true, - "description": "Whether to enable scheduled refresh", - "title": "Scheduled Refresh", - "type": "boolean" - }, - "time_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time dimension for temporal grouping", - "title": "Time Dimension" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" + }, + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name", + "type" + ], + "title": "Dimension", + "type": "object" + }, + "Metric": { + "$defs": { + "RelationshipOverride": { + "description": "Overrides join behavior for a relationship in a metric/query context.", + "properties": { + "from_model": { + "description": "Source model name", + "title": "From Model", + "type": "string" + }, + "from_column": { + "description": "Source model column", + "title": "From Column", + "type": "string" + }, + "to_model": { + "description": "Target model name", + "title": "To Model", + "type": "string" + }, + "to_column": { + "description": "Target model column", + "title": "To Column", + "type": "string" + }, + "join_type": { + "anyOf": [ + { + "enum": [ + "inner", + "left", + "right", + "full" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional join type override", + "title": "Join Type" + }, + "direction": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional filter direction hint", + "title": "Direction" + } + }, + "required": [ + "from_model", + "from_column", + "to_model", + "to_column" + ], + "title": "RelationshipOverride", + "type": "object" + } + }, + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "relationship_overrides": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/RelationshipOverride" }, - "type": { - "default": "rollup", - "description": "Pre-aggregation type", - "enum": [ - "rollup", - "original_sql", - "rollup_join", - "lambda" - ], - "title": "Type", + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Relationship overrides for this metric", + "title": "Relationship Overrides" + }, + "required_models": { + "anyOf": [ + { + "items": { "type": "string" - } + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Additional models required for this metric", + "title": "Required Models" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "window_expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" + }, + "window_frame": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" + }, + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio", + "previous_value" + ], + "type": "string" }, - "required": [ - "name" - ], - "title": "PreAggregation", - "type": "object" - }, - "RefreshKey": { - "description": "Refresh strategy configuration for pre-aggregations.", - "properties": { - "every": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", - "title": "Every" - }, - "incremental": { - "default": false, - "description": "Whether to use incremental refresh (only update changed partitions)", - "title": "Incremental", - "type": "boolean" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL query that returns a value to trigger refresh when changed", - "title": "Sql" - }, - "update_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", - "title": "Update Window" - } + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "entity": { + "anyOf": [ + { + "type": "string" }, - "title": "RefreshKey", - "type": "object" - }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Name of the related model", - "title": "Name", - "type": "string" - }, - "primary_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "type": { - "description": "Type of relationship", - "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" - ], - "title": "Type", - "type": "string" - } + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "base_event": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" - }, - "Segment": { - "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "name": { - "description": "Unique segment name", - "title": "Name", + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "steps": { + "anyOf": [ + { + "items": { "type": "string" }, - "public": { - "default": true, - "description": "Whether segment is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "sql": { - "description": "SQL WHERE clause expression", - "title": "Sql", - "type": "string" - } + "type": "array" }, - "required": [ - "name", - "sql" - ], - "title": "Segment", - "type": "object" - } + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" }, - "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", - "properties": { - "auto_dimensions": { - "default": false, - "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", - "title": "Auto Dimensions", - "type": "boolean" - }, - "default_grain": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" + "cohort_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" }, - { - "type": "null" - } - ], - "default": null, - "description": "Default time granularity when using default_time_dimension", - "title": "Default Grain" - }, - "default_time_dimension": { - "anyOf": [ - { + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Default time dimension for metrics (auto-included in queries)", - "title": "Default Time Dimension" - }, - "description": { - "anyOf": [ - { + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" + }, + "filters": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "dimensions": { - "description": "Dimension definitions", - "items": { - "$ref": "#/$defs/Dimension" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" }, - "title": "Dimensions", - "type": "array" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent model to inherit from", - "title": "Extends" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "metrics": { - "description": "Measure definitions", - "items": { - "$ref": "#/$defs/Metric" + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "format": { + "anyOf": [ + { + "type": "string" }, - "title": "Metrics", - "type": "array" - }, - "name": { - "description": "Unique model name", - "title": "Name", - "type": "string" - }, - "pre_aggregations": { - "description": "Pre-aggregation definitions for query optimization", - "items": { - "$ref": "#/$defs/PreAggregation" + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" }, - "title": "Pre Aggregations", - "type": "array" - }, - "primary_key": { - "anyOf": [ - { + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "drill_fields": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "id", - "description": "Primary key column(s)", - "title": "Primary Key" - }, - "relationships": { - "description": "Relationships to other models", - "items": { - "$ref": "#/$defs/Relationship" + "type": "array" }, - "title": "Relationships", - "type": "array" - }, - "segments": { - "description": "Segment (named filter) definitions", - "items": { - "$ref": "#/$defs/Segment" + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" + }, + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" }, - "title": "Segments", - "type": "array" - }, - "source_uri": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Remote data source URI (e.g., https://, s3://, gs://)", - "title": "Source Uri" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for derived tables", - "title": "Sql" - }, - "table": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Physical table name (schema.table)", - "title": "Table" - }, - "unique_keys": { - "anyOf": [ - { - "items": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Unique key constraints (each is a list of columns)", - "title": "Unique Keys" - } + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" }, - "required": [ - "name" - ], - "title": "Model", - "type": "object" - }, - "type": "array" - }, - "parameters": { - "description": "Parameter definitions for dynamic queries", - "items": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" - }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "description": { - "anyOf": [ - { + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", + "properties": { + "name": { + "description": "Name of the related model", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Type of relationship", + "enum": [ + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" + ], + "title": "Type", + "type": "string" + }, + "foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - } + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" + "through": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Junction model for many_to_many relationships", + "title": "Through" + }, + "through_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" + }, + "related_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" + }, + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + } + }, + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "Parameter": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + } }, - "type": "array" + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" } - }, - "required": [ - "models" - ], - "title": "Sidemantic Semantic Layer", - "type": "object" + } } \ No newline at end of file diff --git a/sidemantic/adapters/sidemantic.py b/sidemantic/adapters/sidemantic.py index 1fae00a7..13b280bb 100644 --- a/sidemantic/adapters/sidemantic.py +++ b/sidemantic/adapters/sidemantic.py @@ -11,7 +11,7 @@ from sidemantic.core.metric import Metric from sidemantic.core.model import Model from sidemantic.core.parameter import Parameter -from sidemantic.core.relationship import Relationship +from sidemantic.core.relationship import Relationship, RelationshipOverride from sidemantic.core.segment import Segment from sidemantic.core.semantic_graph import SemanticGraph from sidemantic.core.sql_definitions import ( @@ -95,6 +95,9 @@ class SidemanticAdapter(BaseAdapter): ``` """ + def __init__(self, lower_dax: bool = True): + self.lower_dax = lower_dax + def parse(self, source: str | Path) -> SemanticGraph: """Parse Sidemantic YAML or SQL into semantic graph. @@ -157,6 +160,7 @@ def parse(self, source: str | Path) -> SemanticGraph: graph.add_parameter(param) # Segments need to be attached to models, skip if no model + self._lower_dax_if_enabled(graph) return graph # Handle YAML files @@ -199,8 +203,16 @@ def parse(self, source: str | Path) -> SemanticGraph: # Note: segments need to be attached to models # For now, skip graph-level segments + self._lower_dax_if_enabled(graph) return graph + def _lower_dax_if_enabled(self, graph: SemanticGraph) -> None: + if not self.lower_dax: + return + from sidemantic.dax.modeling import lower_dax_graph_expressions + + lower_dax_graph_expressions(graph) + def export(self, graph: SemanticGraph, output_path: str | Path) -> None: """Export semantic graph to Sidemantic YAML. @@ -247,6 +259,7 @@ def _parse_model(self, model_def: dict) -> Model | None: through=relationship_def.get("through"), through_foreign_key=relationship_def.get("through_foreign_key"), related_foreign_key=relationship_def.get("related_foreign_key"), + active=relationship_def.get("active", True), ) joins.append(join) @@ -257,12 +270,15 @@ def _parse_model(self, model_def: dict) -> Model | None: name=dim_def.get("name"), type=dim_def.get("type", "categorical"), # Default to categorical sql=dim_def.get("sql") or dim_def.get("expr"), + dax=dim_def.get("dax"), + expression_language=dim_def.get("expression_language"), granularity=dim_def.get("granularity"), supported_granularities=dim_def.get("supported_granularities"), description=dim_def.get("description"), label=dim_def.get("label"), format=dim_def.get("format"), value_format_name=dim_def.get("value_format_name"), + public=dim_def.get("public", True), parent=dim_def.get("parent"), metadata=dim_def.get("metadata"), window=dim_def.get("window"), @@ -277,6 +293,8 @@ def _parse_model(self, model_def: dict) -> Model | None: extends=measure_def.get("extends"), agg=measure_def.get("agg"), sql=measure_def.get("sql") or measure_def.get("expr"), + dax=measure_def.get("dax"), + expression_language=measure_def.get("expression_language"), type=measure_def.get("type"), filters=measure_def.get("filters"), fill_nulls_with=measure_def.get("fill_nulls_with"), @@ -284,9 +302,12 @@ def _parse_model(self, model_def: dict) -> Model | None: label=measure_def.get("label"), format=measure_def.get("format"), value_format_name=measure_def.get("value_format_name"), + public=measure_def.get("public", True), drill_fields=measure_def.get("drill_fields"), non_additive_dimension=measure_def.get("non_additive_dimension"), metadata=measure_def.get("metadata"), + relationship_overrides=_parse_relationship_overrides(measure_def.get("relationship_overrides")), + required_models=measure_def.get("required_models"), base_metric=measure_def.get("base_metric"), comparison_type=measure_def.get("comparison_type"), time_offset=measure_def.get("time_offset"), @@ -376,6 +397,8 @@ def _parse_model(self, model_def: dict) -> Model | None: name=name, table=model_def.get("table"), sql=model_def.get("sql"), + dax=model_def.get("dax"), + expression_language=model_def.get("expression_language"), source_uri=model_def.get("source_uri"), description=model_def.get("description"), extends=model_def.get("extends"), @@ -414,6 +437,8 @@ def _parse_metric(self, metric_def: dict) -> Metric | None: label=metric_def.get("label"), metadata=metric_def.get("metadata"), sql=metric_def.get("sql") or metric_def.get("expr") or metric_def.get("measure"), + dax=metric_def.get("dax"), + expression_language=metric_def.get("expression_language"), agg=metric_def.get("agg"), numerator=metric_def.get("numerator"), denominator=metric_def.get("denominator"), @@ -443,10 +468,13 @@ def _parse_metric(self, metric_def: dict) -> Metric | None: window_order=metric_def.get("window_order"), filters=metric_def.get("filters"), fill_nulls_with=metric_def.get("fill_nulls_with"), + public=metric_def.get("public", True), format=metric_def.get("format"), value_format_name=metric_def.get("value_format_name"), drill_fields=metric_def.get("drill_fields"), non_additive_dimension=metric_def.get("non_additive_dimension"), + relationship_overrides=_parse_relationship_overrides(metric_def.get("relationship_overrides")), + required_models=metric_def.get("required_models"), ) def _parse_parameter(self, parameter_def: dict) -> Parameter | None: @@ -484,10 +512,14 @@ def _export_model(self, model: Model) -> dict: Model definition dictionary """ result = {"name": model.name} + model_dax = _dax_text(model) - if model.table: + if model_dax: + result["dax"] = model_dax + result["expression_language"] = "dax" + elif model.table: result["table"] = model.table - if model.sql: + if model.sql and not model_dax: result["sql"] = model.sql if model.source_uri: result["source_uri"] = model.source_uri @@ -516,7 +548,7 @@ def _export_model(self, model: Model) -> dict: if relationship.related_foreign_key else {} ), - **({"metadata": relationship.metadata} if relationship.metadata else {}), + **({"active": relationship.active} if relationship.active is not True else {}), } for relationship in model.relationships ] @@ -533,7 +565,11 @@ def _export_model(self, model: Model) -> dict: "name": dim.name, "type": dim.type, } - if dim.sql: + dim_dax = _dax_text(dim) + if dim_dax: + dim_def["dax"] = dim_dax + dim_def["expression_language"] = "dax" + elif dim.sql: dim_def["sql"] = dim.sql if dim.granularity: dim_def["granularity"] = dim.granularity @@ -551,6 +587,8 @@ def _export_model(self, model: Model) -> dict: dim_def["parent"] = dim.parent if dim.window: dim_def["window"] = dim.window + if not dim.public: + dim_def["public"] = dim.public result["dimensions"].append(dim_def) # Export metrics (model-level aggregations) @@ -561,7 +599,11 @@ def _export_model(self, model: Model) -> dict: "name": measure.name, "agg": measure.agg, } - if measure.sql: + measure_dax = _dax_text(measure) + if measure_dax: + measure_def["dax"] = measure_dax + measure_def["expression_language"] = "dax" + elif measure.sql: measure_def["sql"] = measure.sql if measure.filters: measure_def["filters"] = measure.filters @@ -579,6 +621,12 @@ def _export_model(self, model: Model) -> dict: measure_def["drill_fields"] = measure.drill_fields if measure.non_additive_dimension: measure_def["non_additive_dimension"] = measure.non_additive_dimension + if measure.relationship_overrides: + measure_def["relationship_overrides"] = [ + _relationship_override_dict(override) for override in measure.relationship_overrides + ] + if measure.required_models: + measure_def["required_models"] = measure.required_models if measure.type: measure_def["type"] = measure.type if measure.base_metric: @@ -628,6 +676,8 @@ def _export_model(self, model: Model) -> dict: measure_def["window_frame"] = measure.window_frame if measure.window_order: measure_def["window_order"] = measure.window_order + if not measure.public: + measure_def["public"] = measure.public result["metrics"].append(measure_def) # Export model-level default_time_dimension @@ -715,17 +765,55 @@ def _export_metric(self, measure: Metric, graph) -> dict: if measure.having: result["having"] = measure.having if measure.sql: - result["sql"] = measure.sql - # Auto-detect and export dependencies for derived measures - if measure.type == "derived": - dependencies = measure.get_dependencies(graph) - if dependencies: - result["metrics"] = list(dependencies) + measure_dax = _dax_text(measure) + if measure_dax: + result["dax"] = measure_dax + result["expression_language"] = "dax" + else: + result["sql"] = measure.sql + # Auto-detect and export dependencies for derived measures + if measure.type == "derived": + dependencies = measure.get_dependencies(graph) + if dependencies: + result["metrics"] = list(dependencies) + else: + measure_dax = _dax_text(measure) + if measure_dax: + result["dax"] = measure_dax + result["expression_language"] = "dax" if measure.agg: result["agg"] = measure.agg if measure.window: result["window"] = measure.window if measure.filters: result["filters"] = measure.filters + if measure.relationship_overrides: + result["relationship_overrides"] = [ + _relationship_override_dict(override) for override in measure.relationship_overrides + ] + if measure.required_models: + result["required_models"] = measure.required_models + if not measure.public: + result["public"] = measure.public return result + + +def _parse_relationship_overrides(value) -> list[RelationshipOverride] | None: + if not value: + return None + return [item if isinstance(item, RelationshipOverride) else RelationshipOverride(**item) for item in value] + + +def _relationship_override_dict(override: RelationshipOverride) -> dict: + return override.model_dump(exclude_none=True) + + +def _dax_text(obj) -> str | None: + dax = getattr(obj, "dax", None) + if isinstance(dax, str) and dax.strip(): + return dax + expression = getattr(obj, "_dax_expression", None) + if isinstance(expression, str) and expression.strip(): + return expression + return None diff --git a/sidemantic/adapters/tmdl.py b/sidemantic/adapters/tmdl.py new file mode 100644 index 00000000..1980f061 --- /dev/null +++ b/sidemantic/adapters/tmdl.py @@ -0,0 +1,2278 @@ +"""TMDL adapter for importing/exporting Power BI Tabular Model Definition Language files.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from sidemantic.adapters.base import BaseAdapter +from sidemantic.adapters.tmdl_parser import TmdlExpression, TmdlNode, TmdlParser, TmdlProperty, merge_documents +from sidemantic.core.dimension import Dimension +from sidemantic.core.metric import Metric +from sidemantic.core.model import Model +from sidemantic.core.relationship import Relationship +from sidemantic.core.semantic_graph import SemanticGraph +from sidemantic.dax import ( + DaxTranslationError, + RelationshipEdge, + translate_dax_metric, + translate_dax_scalar, + translate_dax_table, +) + +if TYPE_CHECKING: + from sidemantic_dax.ast import Expr as DaxExpr + + +TmdlImportWarning = dict[str, Any] +TmdlExportWarning = dict[str, Any] + + +class DaxRuntimeUnavailableError(RuntimeError): + """Raised when TMDL contains DAX but the optional DAX parser is unavailable.""" + + +class TMDLAdapter(BaseAdapter): + """Adapter for importing/exporting Power BI TMDL models.""" + + def parse(self, source: str | Path) -> SemanticGraph: + """Parse TMDL files into semantic graph.""" + source_path = Path(source) + if not source_path.exists(): + raise FileNotFoundError(source) + + tmdl_root, files = _collect_tmdl_files(source_path) + parser = TmdlParser() + documents = [parser.parse(path.read_text(), file=_display_tmdl_path(path, tmdl_root)) for path in files] + merged_nodes = merge_documents(documents) + + graph = SemanticGraph() + warnings: list[TmdlImportWarning] = [] + database_passthrough_node = _select_database_passthrough_node(merged_nodes) + model_passthrough_node = _select_model_passthrough_node(merged_nodes) + relationship_nodes = [node for node in _find_nodes(merged_nodes, {"relationship"}) if not _is_ref_only(node)] + table_nodes = [ + node for node in _find_nodes(merged_nodes, {"table", "calculatedtable"}) if not _is_ref_only(node) + ] + table_names = {node.name for node in table_nodes if node.name} + relationship_edges = _collect_relationship_edges(relationship_nodes, known_tables=table_names) + column_sql_by_table, measure_names_by_table, measure_aggs_by_table, time_dimensions_by_table = ( + _collect_table_metadata(table_nodes) + ) + + for table_node in table_nodes: + model = _table_to_model( + table_node, + tmdl_root, + column_sql_by_table, + measure_names_by_table, + measure_aggs_by_table, + time_dimensions_by_table, + relationship_edges, + warnings, + ) + model._source_format = "TMDL" + graph.add_model(model) + + _apply_relationships(graph, relationship_nodes, tmdl_root, warnings) + + if database_passthrough_node is not None: + if database_passthrough_node.name: + graph._tmdl_database_name = database_passthrough_node.name + if database_passthrough_node.name_raw: + graph._tmdl_database_name_raw = database_passthrough_node.name_raw + if database_passthrough_node.leading_comments: + graph._tmdl_database_leading_comments = list(database_passthrough_node.leading_comments) + model_ref_node = next( + (child for child in database_passthrough_node.children if child.type.lower() == "model" and child.name), + None, + ) + model_ref = model_ref_node.name if model_ref_node else None + if model_ref: + graph._tmdl_database_model_name = model_ref + if model_ref_node and model_ref_node.name_raw: + graph._tmdl_database_model_name_raw = model_ref_node.name_raw + if database_passthrough_node.description: + graph._tmdl_database_description = database_passthrough_node.description + database_props = _node_passthrough_properties(database_passthrough_node, set()) + if database_props: + graph._tmdl_database_properties = database_props + database_children = [ + _clone_tmdl_node(child) for child in database_passthrough_node.children if child.type.lower() != "model" + ] + if database_children: + graph._tmdl_database_child_nodes = database_children + + if model_passthrough_node is not None: + if model_passthrough_node.name: + graph._tmdl_model_name = model_passthrough_node.name + if model_passthrough_node.name_raw: + graph._tmdl_model_name_raw = model_passthrough_node.name_raw + if model_passthrough_node.leading_comments: + graph._tmdl_model_leading_comments = list(model_passthrough_node.leading_comments) + if model_passthrough_node.description: + graph._tmdl_model_description = model_passthrough_node.description + model_props = _node_passthrough_properties(model_passthrough_node, set()) + if model_props: + graph._tmdl_model_properties = model_props + model_table_refs = [ + (child.name, child.name_raw) + for child in model_passthrough_node.children + if child.is_ref and child.type.lower() == "table" and child.name + ] + if model_table_refs: + graph._tmdl_model_table_refs = model_table_refs + model_relationship_refs = [ + (child.name, child.name_raw) + for child in model_passthrough_node.children + if child.is_ref and child.type.lower() == "relationship" and child.name + ] + if model_relationship_refs: + graph._tmdl_model_relationship_refs = model_relationship_refs + model_children = [ + _clone_tmdl_node(child) + for child in model_passthrough_node.children + if not _is_model_ref_node(child) + and child.type.lower() not in {"table", "calculatedtable", "relationship"} + ] + if model_children: + graph._tmdl_model_child_nodes = model_children + + root_passthrough_nodes = _collect_graph_root_passthrough_nodes(merged_nodes) + if root_passthrough_nodes: + graph._tmdl_root_nodes = root_passthrough_nodes + + graph.build_adjacency() + graph.import_warnings = warnings + return graph + + def export(self, graph: SemanticGraph, output_path: str | Path) -> None: + """Export semantic graph to a TMDL folder structure.""" + output_path = Path(output_path) + if output_path.suffix: + output_path.parent.mkdir(parents=True, exist_ok=True) + export_warnings: list[TmdlExportWarning] = [] + output_path.write_text(_export_script(graph, output_path.stem, export_warnings)) + graph.export_warnings = export_warnings + return + + output_path.mkdir(parents=True, exist_ok=True) + definition_dir = output_path / "definition" + definition_dir.mkdir(parents=True, exist_ok=True) + tables_dir = definition_dir / "tables" + tables_dir.mkdir(parents=True, exist_ok=True) + + project_name = output_path.name + + (definition_dir / "database.tmdl").write_text(_export_database(graph, project_name)) + (definition_dir / "model.tmdl").write_text(_export_model(graph, project_name)) + + for model in graph.models.values(): + table_file = _export_table_file_path(tables_dir, model) + table_file.write_text(_export_table(model)) + + export_warnings: list[TmdlExportWarning] = [] + relationships_text = _export_relationships(graph, export_warnings) + if relationships_text: + (definition_dir / "relationships.tmdl").write_text(relationships_text) + graph.export_warnings = export_warnings + + +def _collect_tmdl_files(source_path: Path) -> tuple[Path, list[Path]]: + if source_path.is_file(): + return source_path.parent, [source_path] + + definition_dir = source_path / "definition" + root = definition_dir if definition_dir.is_dir() else source_path + files = sorted(root.rglob("*.tmdl")) + return root, files + + +def _display_tmdl_path(path: Path, root: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + +def _export_table_file_path(tables_dir: Path, model: Model) -> Path: + source_file = getattr(model, "_source_file", None) + if isinstance(source_file, str) and source_file: + source_path = Path(source_file) + if source_path.suffix.lower() == ".tmdl" and source_path.parent == Path("tables"): + return tables_dir / source_path.name + return tables_dir / f"{_safe_filename(model.name)}.tmdl" + + +def _find_nodes(nodes: list[TmdlNode], types: set[str]) -> list[TmdlNode]: + found: list[TmdlNode] = [] + for node in nodes: + if node.type.lower() in types: + found.append(node) + if node.children: + found.extend(_find_nodes(node.children, types)) + return found + + +def _is_ref_only(node: TmdlNode) -> bool: + return node.is_ref and not node.properties and not node.children and node.default_property is None + + +def _is_model_ref_node(node: TmdlNode) -> bool: + if not node.is_ref: + return False + return node.type.lower() in {"table", "relationship"} + + +def _select_model_passthrough_node(nodes: list[TmdlNode]) -> TmdlNode | None: + candidates = [node for node in _find_nodes(nodes, {"model"}) if not node.is_ref] + if not candidates: + return None + + def score(node: TmdlNode) -> tuple[int, int]: + rich = int( + bool(node.properties) + or bool(node.children) + or bool(node.description) + or bool(node.default_property) + or bool(node.leading_comments) + ) + size = len(node.properties) + len(node.children) + return (rich, size) + + return max(candidates, key=score) + + +def _select_database_passthrough_node(nodes: list[TmdlNode]) -> TmdlNode | None: + candidates = [node for node in _find_nodes(nodes, {"database"}) if not node.is_ref] + if not candidates: + return None + + def score(node: TmdlNode) -> tuple[int, int]: + rich = int( + bool(node.properties) + or bool(node.children) + or bool(node.description) + or bool(node.default_property) + or bool(node.leading_comments) + ) + size = len(node.properties) + len(node.children) + return (rich, size) + + return max(candidates, key=score) + + +def _collect_graph_root_passthrough_nodes(nodes: list[TmdlNode]) -> list[TmdlNode]: + passthrough: list[TmdlNode] = [] + for node in nodes: + node_type = node.type.lower() + if node_type == "createorreplace": + passthrough.extend(_collect_graph_root_passthrough_nodes(node.children)) + continue + if node_type in {"database", "model", "table", "calculatedtable", "relationship"}: + continue + passthrough.append(_clone_tmdl_node(node)) + return passthrough + + +def _collect_table_metadata( + table_nodes: list[TmdlNode], +) -> tuple[ + dict[str, dict[str, str]], + dict[str, set[str]], + dict[str, dict[str, str]], + dict[str, set[str]], +]: + column_sql_by_table: dict[str, dict[str, str]] = {} + measure_names_by_table: dict[str, set[str]] = {} + measure_aggs_by_table: dict[str, dict[str, str]] = {} + time_dimensions_by_table: dict[str, set[str]] = {} + + for table_node in table_nodes: + table_name = table_node.name or "" + column_sql: dict[str, str] = {} + measure_names: set[str] = set() + measure_aggs: dict[str, str] = {} + time_dimensions: set[str] = set() + + for child in table_node.children: + child_type = child.type.lower() + if child_type in ("column", "calculatedcolumn"): + props = _props(child) + dim_type, _ = _map_data_type(_string_prop(props.get("datatype"))) + expression = _resolve_expression(child, props) + source_column = _string_prop(props.get("sourcecolumn")) + sql = source_column or expression or (child.name or "") + column_sql[child.name or ""] = sql + if dim_type == "time" and child.name: + time_dimensions.add(child.name) + elif child_type == "measure": + measure_name = child.name or "" + measure_names.add(measure_name) + expr_text = _resolve_expression(child, _props(child)) + if expr_text: + agg, _sql = _extract_dax_agg(expr_text, table_name) + if agg: + measure_aggs[measure_name] = agg + + column_sql_by_table[table_name] = column_sql + measure_names_by_table[table_name] = measure_names + measure_aggs_by_table[table_name] = measure_aggs + time_dimensions_by_table[table_name] = time_dimensions + + return column_sql_by_table, measure_names_by_table, measure_aggs_by_table, time_dimensions_by_table + + +def _table_to_model( + node: TmdlNode, + root: Path, + column_sql_by_table: dict[str, dict[str, str]], + measure_names_by_table: dict[str, set[str]], + measure_aggs_by_table: dict[str, dict[str, str]], + time_dimensions_by_table: dict[str, set[str]], + relationship_edges: list[RelationshipEdge], + warnings: list[TmdlImportWarning], +) -> Model: + props = _props(node) + description = node.description or _string_prop(props.get("description")) + dimensions: list[Dimension] = [] + metrics: list[Metric] = [] + passthrough_children: list[TmdlNode] = [] + primary_key = None + original_expression: str | None = None + + for child in node.children: + child_type = child.type.lower() + if child_type in ("column", "calculatedcolumn"): + dim = _column_to_dimension( + child, + node.name or "", + root, + column_sql_by_table, + measure_names_by_table, + time_dimensions_by_table, + warnings, + ) + dimensions.append(dim) + if _is_true(_props(child).get("iskey")): + primary_key = dim.name + elif child_type == "measure": + parsed_metrics = _measure_to_metric( + child, + node.name or "", + root, + column_sql_by_table, + measure_names_by_table, + measure_aggs_by_table, + time_dimensions_by_table, + relationship_edges, + warnings, + ) + if parsed_metrics: + metrics.extend(parsed_metrics) + else: + passthrough_children.append(_clone_tmdl_node(child)) + + model_sql = None + model_table = node.name or None + model_required_models: list[str] | None = None + if node.type.lower() == "calculatedtable": + expression_obj = _resolve_expression_object(node, props) + expr_text = expression_obj.text if expression_obj else None + original_expression = expr_text + if expr_text: + try: + dax_expr = _parse_dax_expression(expr_text, node, "table") + except DaxRuntimeUnavailableError as exc: + _append_import_warning( + warnings, + node, + code="dax_parser_unavailable", + context="calculated_table", + message=str(exc), + model_name=node.name, + ) + dax_expr = None + except ValueError as exc: + _append_import_warning( + warnings, + node, + code="dax_parse_error", + context="calculated_table", + message=str(exc), + model_name=node.name, + ) + dax_expr = None + if dax_expr is not None: + try: + translation = translate_dax_table( + dax_expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + relationship_edges=relationship_edges, + ) + model_sql = translation.sql + model_required_models = sorted(translation.required_models) + _append_dax_translation_warnings( + warnings, + node, + context="calculated_table", + model_name=node.name, + translation_warnings=translation.warnings, + ) + except DaxTranslationError as exc: + _append_import_warning( + warnings, + node, + code="dax_translation_fallback", + context="calculated_table", + message=str(exc), + model_name=node.name, + ) + model_sql = None + if model_sql: + model_table = None + + model = Model( + name=node.name or "", + table=model_table, + sql=model_sql, + description=description, + primary_key=primary_key or "id", + dimensions=dimensions, + metrics=metrics, + default_time_dimension=_find_default_time_dimension(dimensions), + default_grain=_find_default_grain(dimensions), + ) + if node.name_raw: + model._tmdl_name_raw = node.name_raw + if node.leading_comments: + model._tmdl_leading_comments = list(node.leading_comments) + + if node.location and node.location.file: + try: + model._source_file = str(Path(node.location.file).relative_to(root)) + except ValueError: + model._source_file = node.location.file + if node.type.lower() == "calculatedtable": + model._tmdl_node_type = "calculatedTable" + if original_expression: + model._tmdl_expression = original_expression + model.dax = original_expression + model._dax_skip_native_lowering = True + model._dax_lowered = model_sql is not None + expression_obj = _resolve_expression_object(node, props) + if expression_obj is not None: + model._tmdl_expression_obj = _clone_tmdl_value(expression_obj) + if "dax_expr" in locals() and dax_expr is not None: + model._dax_ast = dax_expr + if model_required_models: + model._dax_required_models = model_required_models + table_props = _node_passthrough_properties(node, {"description", "expression"}) + if table_props: + model._tmdl_properties = table_props + raw_value_props = _node_raw_value_properties(node) + if raw_value_props: + model._tmdl_raw_value_properties = raw_value_props + if passthrough_children: + model._tmdl_child_nodes = passthrough_children + + return model + + +def _column_to_dimension( + node: TmdlNode, + table_name: str, + root: Path, + column_sql_by_table: dict[str, dict[str, str]], + measure_names_by_table: dict[str, set[str]], + time_dimensions_by_table: dict[str, set[str]], + warnings: list[TmdlImportWarning], +) -> Dimension: + props = _props(node) + data_type = _string_prop(props.get("datatype")) + dim_type, granularity = _map_data_type(data_type) + + expression_obj = _resolve_expression_object(node, props) + expression = expression_obj.text if expression_obj else None + + source_column = _string_prop(props.get("sourcecolumn")) + sql = source_column or expression + if expression: + try: + dax_expr = _parse_dax_expression(expression, node, "column") + except DaxRuntimeUnavailableError as exc: + _append_import_warning( + warnings, + node, + code="dax_parser_unavailable", + context="column", + message=str(exc), + model_name=table_name, + ) + dax_expr = None + except ValueError as exc: + _append_import_warning( + warnings, + node, + code="dax_parse_error", + context="column", + message=str(exc), + model_name=table_name, + ) + dax_expr = None + else: + dax_expr = None + dax_lowered = False + if dax_expr is not None and expression: + try: + sql = translate_dax_scalar( + dax_expr, + model_name=table_name, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + time_dimensions_by_table=time_dimensions_by_table, + ) + dax_lowered = True + except DaxTranslationError as exc: + _append_import_warning( + warnings, + node, + code="dax_translation_fallback", + context="column", + message=str(exc), + model_name=table_name, + ) + sql = source_column or expression + if not sql: + sql = node.name or "" + + dimension = Dimension( + name=node.name or "", + type=dim_type, + sql=sql, + dax=expression, + granularity=granularity, + description=node.description or _string_prop(props.get("description")), + label=_string_prop(props.get("caption")), + format=_string_prop(props.get("formatstring")), + public=not _is_true(props.get("ishidden")), + ) + if expression: + dimension._dax_skip_native_lowering = True + dimension._dax_lowered = dax_lowered + dimension._source_format = "TMDL" + if node.location and node.location.file: + try: + dimension._source_file = str(Path(node.location.file).relative_to(root)) + except ValueError: + dimension._source_file = node.location.file + if dax_expr is not None: + dimension._dax_ast = dax_expr + if node.name_raw: + dimension._tmdl_name_raw = node.name_raw + if node.leading_comments: + dimension._tmdl_leading_comments = list(node.leading_comments) + if expression: + dimension._tmdl_expression = expression + if expression_obj is not None: + dimension._tmdl_expression_obj = _clone_tmdl_value(expression_obj) + if data_type: + dimension._tmdl_data_type = data_type + if node.type: + dimension._tmdl_node_type = node.type + column_props = _node_passthrough_properties( + node, + {"datatype", "iskey", "caption", "formatstring", "description", "sourcecolumn", "expression", "ishidden"}, + ) + if column_props: + dimension._tmdl_properties = column_props + raw_value_props = _node_raw_value_properties(node) + if raw_value_props: + dimension._tmdl_raw_value_properties = raw_value_props + property_order = [prop.name.lower() for prop in node.properties if isinstance(prop.name, str)] + if property_order: + dimension._tmdl_property_order = property_order + if node.children: + dimension._tmdl_child_nodes = [_clone_tmdl_node(child) for child in node.children] + return dimension + + +def _measure_to_metric( + node: TmdlNode, + table_name: str, + root: Path, + column_sql_by_table: dict[str, dict[str, str]], + measure_names_by_table: dict[str, set[str]], + measure_aggs_by_table: dict[str, dict[str, str]], + time_dimensions_by_table: dict[str, set[str]], + relationship_edges: list[RelationshipEdge], + warnings: list[TmdlImportWarning], +) -> list[Metric]: + props = _props(node) + expression_obj = _resolve_expression_object(node, props) + expression = expression_obj.text if expression_obj else None + + if not expression: + return [] + + try: + dax_expr = _parse_dax_expression(expression, node, "measure") + except DaxRuntimeUnavailableError as exc: + _append_import_warning( + warnings, + node, + code="dax_parser_unavailable", + context="measure", + message=str(exc), + model_name=table_name, + ) + dax_expr = None + except ValueError as exc: + _append_import_warning( + warnings, + node, + code="dax_parse_error", + context="measure", + message=str(exc), + model_name=table_name, + ) + dax_expr = None + translation = None + if dax_expr is not None: + try: + translation = translate_dax_metric( + dax_expr, + model_name=table_name, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table=measure_aggs_by_table, + time_dimensions_by_table=time_dimensions_by_table, + relationship_edges=relationship_edges, + ) + except DaxTranslationError as exc: + _append_import_warning( + warnings, + node, + code="dax_translation_fallback", + context="measure", + message=str(exc), + model_name=table_name, + ) + translation = None + + if translation: + metric_type = translation.type + if metric_type is None and translation.sql and not translation.agg: + metric_type = "derived" + inline_metrics: list[Metric] = [] + base_metric_ref = translation.base_metric + if ( + metric_type == "time_comparison" + and not base_metric_ref + and translation.inline_base_agg + and translation.inline_base_sql + ): + base_metric_name = _inline_base_metric_name( + node.name or "metric", measure_names_by_table.get(table_name, set()) + ) + base_metric_ref = f"{table_name}.{base_metric_name}" + inline_metrics.append( + Metric( + name=base_metric_name, + agg=translation.inline_base_agg, + sql=translation.inline_base_sql, + filters=translation.inline_base_filters or None, + ) + ) + + metric = Metric( + name=node.name or "", + agg=translation.agg, + sql=translation.sql, + dax=expression, + type=metric_type, + base_metric=base_metric_ref, + comparison_type=translation.comparison_type, + calculation=translation.calculation, + time_offset=translation.time_offset, + window=translation.window, + grain_to_date=translation.grain_to_date, + window_order=translation.window_order, + filters=translation.filters or None, + relationship_overrides=translation.relationship_overrides or None, + required_models=sorted(translation.required_models) if translation.required_models else None, + description=node.description or _string_prop(_props(node).get("description")), + label=_string_prop(_props(node).get("caption")), + format=_string_prop(_props(node).get("formatstring")), + public=not _is_true(props.get("ishidden")), + ) + metric._dax_lowered = True + metric._dax_skip_native_lowering = True + metric._source_format = "TMDL" + if node.location and node.location.file: + try: + metric._source_file = str(Path(node.location.file).relative_to(root)) + except ValueError: + metric._source_file = node.location.file + if dax_expr is not None: + metric._dax_ast = dax_expr + if node.name_raw: + metric._tmdl_name_raw = node.name_raw + if node.leading_comments: + metric._tmdl_leading_comments = list(node.leading_comments) + metric._tmdl_expression = expression + if expression_obj is not None: + metric._tmdl_expression_obj = _clone_tmdl_value(expression_obj) + measure_props = _node_passthrough_properties( + node, {"caption", "formatstring", "description", "expression", "ishidden"} + ) + if measure_props: + metric._tmdl_properties = measure_props + raw_value_props = _node_raw_value_properties(node) + if raw_value_props: + metric._tmdl_raw_value_properties = raw_value_props + property_order = [prop.name.lower() for prop in node.properties if isinstance(prop.name, str)] + if property_order: + metric._tmdl_property_order = property_order + if node.children: + metric._tmdl_child_nodes = [_clone_tmdl_node(child) for child in node.children] + inline_metrics.append(metric) + return inline_metrics + + agg, sql = _extract_dax_agg(expression, table_name, dax_expr) + metric_type = None if agg else "derived" + metric = Metric( + name=node.name or "", + agg=agg, + sql=sql or expression if not agg else sql, + dax=expression, + type=metric_type, + description=node.description or _string_prop(_props(node).get("description")), + label=_string_prop(_props(node).get("caption")), + format=_string_prop(_props(node).get("formatstring")), + public=not _is_true(props.get("ishidden")), + ) + if expression: + metric._dax_skip_native_lowering = True + metric._dax_lowered = False + metric._source_format = "TMDL" + if node.location and node.location.file: + try: + metric._source_file = str(Path(node.location.file).relative_to(root)) + except ValueError: + metric._source_file = node.location.file + if dax_expr is not None: + metric._dax_ast = dax_expr + if node.name_raw: + metric._tmdl_name_raw = node.name_raw + if node.leading_comments: + metric._tmdl_leading_comments = list(node.leading_comments) + metric._tmdl_expression = expression + if expression_obj is not None: + metric._tmdl_expression_obj = _clone_tmdl_value(expression_obj) + measure_props = _node_passthrough_properties( + node, {"caption", "formatstring", "description", "expression", "ishidden"} + ) + if measure_props: + metric._tmdl_properties = measure_props + raw_value_props = _node_raw_value_properties(node) + if raw_value_props: + metric._tmdl_raw_value_properties = raw_value_props + property_order = [prop.name.lower() for prop in node.properties if isinstance(prop.name, str)] + if property_order: + metric._tmdl_property_order = property_order + if node.children: + metric._tmdl_child_nodes = [_clone_tmdl_node(child) for child in node.children] + return [metric] + + +def _inline_base_metric_name(metric_name: str, existing_names: set[str]) -> str: + stem_chars: list[str] = [] + for ch in metric_name: + if ch.isalnum(): + stem_chars.append(ch.lower()) + else: + stem_chars.append("_") + stem = "".join(stem_chars).strip("_") or "metric" + candidate = f"__{stem}_base" + if candidate not in existing_names: + return candidate + idx = 2 + while f"{candidate}_{idx}" in existing_names: + idx += 1 + return f"{candidate}_{idx}" + + +def _apply_relationships( + graph: SemanticGraph, nodes: list[TmdlNode], root: Path, warnings: list[TmdlImportWarning] | None = None +) -> None: + for node in nodes: + props = _props(node) + active = not _is_false(props.get("isactive")) + + from_ref = _string_prop(props.get("fromcolumn")) + to_ref = _string_prop(props.get("tocolumn")) + from_table, from_column = _parse_column_reference(from_ref) + to_table, to_column = _parse_column_reference(to_ref) + + if not from_table or not to_table: + if warnings is not None: + _append_import_warning( + warnings, + node, + code="relationship_parse_skip", + context="relationship", + message="Skipping relationship: invalid fromColumn/toColumn reference", + ) + continue + if from_table not in graph.models or to_table not in graph.models: + if warnings is not None: + _append_import_warning( + warnings, + node, + code="relationship_parse_skip", + context="relationship", + message=(f"Skipping relationship: unknown model reference from='{from_table}' to='{to_table}'"), + ) + continue + + from_cardinality = _string_prop(props.get("fromcardinality")) + to_cardinality = _string_prop(props.get("tocardinality")) + rel_type = _map_relationship_type( + from_cardinality, + to_cardinality, + ) + if not rel_type: + if warnings is not None: + _append_import_warning( + warnings, + node, + code="relationship_parse_skip", + context="relationship", + message=( + "Skipping relationship: unsupported cardinality " + f"from='{from_cardinality or ''}' to='{to_cardinality or ''}'" + ), + ) + continue + + if rel_type == "many_to_one": + foreign_key = from_column + primary_key = to_column + elif rel_type in ("one_to_many", "one_to_one"): + foreign_key = to_column + primary_key = None + elif rel_type == "many_to_many": + foreign_key = from_column + primary_key = to_column + else: + foreign_key = None + primary_key = None + + relationship_props = _relationship_passthrough_properties(node) + relationship = Relationship( + name=to_table, + type=rel_type, + foreign_key=foreign_key, + primary_key=primary_key, + active=active, + ) + if "isactive" in props and active: + relationship._tmdl_is_active_explicit = True + relationship._tmdl_from_column = from_column + relationship._tmdl_to_column = to_column + if node.name: + relationship._tmdl_relationship_name = node.name + relationship._source_format = "TMDL" + if node.location and node.location.file: + try: + relationship._source_file = str(Path(node.location.file).relative_to(root)) + except ValueError: + relationship._source_file = node.location.file + if node.name_raw: + relationship._tmdl_relationship_name_raw = node.name_raw + if node.description: + relationship._tmdl_description = node.description + if node.leading_comments: + relationship._tmdl_leading_comments = list(node.leading_comments) + if relationship_props: + relationship._tmdl_relationship_properties = relationship_props + raw_value_props = _node_raw_value_properties(node) + if raw_value_props: + relationship._tmdl_raw_value_properties = raw_value_props + property_order = [prop.name.lower() for prop in node.properties if isinstance(prop.name, str)] + if property_order: + relationship._tmdl_property_order = property_order + if node.children: + relationship._tmdl_child_nodes = [_clone_tmdl_node(child) for child in node.children] + + model = graph.models[from_table] + if not any( + existing.name == relationship.name + and existing.type == relationship.type + and existing.foreign_key == relationship.foreign_key + and existing.primary_key == relationship.primary_key + for existing in model.relationships + ): + model.relationships.append(relationship) + + +def _node_passthrough_properties(node: TmdlNode, excluded_keys: set[str]) -> list[dict[str, Any]]: + passthrough: list[dict[str, Any]] = [] + for prop in node.properties: + prop_key = prop.name.lower() + if prop_key in excluded_keys: + continue + + entry: dict[str, Any] = {"name": prop.name, "kind": prop.kind, "value": _clone_tmdl_value(prop.value)} + if isinstance(prop.raw, str): + entry["raw"] = prop.raw + passthrough.append(entry) + return passthrough + + +def _node_raw_value_properties(node: TmdlNode) -> dict[str, str]: + raw_props: dict[str, str] = {} + for prop in node.properties: + if prop.kind != "value": + continue + if not isinstance(prop.raw, str): + continue + raw_props.setdefault(prop.name.lower(), prop.raw) + return raw_props + + +def _relationship_passthrough_properties(node: TmdlNode) -> list[dict[str, Any]]: + return _node_passthrough_properties( + node, + {"fromcolumn", "tocolumn", "fromcardinality", "tocardinality", "isactive"}, + ) + + +def _clone_tmdl_value(value: Any) -> Any: + if isinstance(value, TmdlExpression): + return TmdlExpression( + text=value.text, + meta=dict(value.meta) if value.meta else None, + meta_raw=value.meta_raw, + is_block=value.is_block, + block_delimiter=value.block_delimiter, + ) + return value + + +def _clone_tmdl_node(node: TmdlNode) -> TmdlNode: + return TmdlNode( + type=node.type, + name=node.name, + name_raw=node.name_raw, + is_ref=node.is_ref, + properties=[ + TmdlProperty( + name=prop.name, + value=_clone_tmdl_value(prop.value), + kind=prop.kind, + raw=prop.raw, + ) + for prop in node.properties + ], + children=[_clone_tmdl_node(child) for child in node.children], + default_property=_clone_tmdl_value(node.default_property), + description=node.description, + leading_comments=list(node.leading_comments), + location=None, + ) + + +def _collect_relationship_edges( + nodes: list[TmdlNode], *, known_tables: set[str] | None = None +) -> list[RelationshipEdge]: + edges: list[RelationshipEdge] = [] + for node in nodes: + props = _props(node) + if _is_false(props.get("isactive")): + continue + from_ref = _string_prop(props.get("fromcolumn")) + to_ref = _string_prop(props.get("tocolumn")) + from_table, from_column = _parse_column_reference(from_ref) + to_table, to_column = _parse_column_reference(to_ref) + if not from_table or not from_column or not to_table or not to_column: + continue + if known_tables is not None and (from_table not in known_tables or to_table not in known_tables): + continue + if not _map_relationship_type( + _string_prop(props.get("fromcardinality")), + _string_prop(props.get("tocardinality")), + ): + continue + edges.append( + RelationshipEdge( + from_table=from_table, + from_column=from_column, + to_table=to_table, + to_column=to_column, + ) + ) + return edges + + +def _find_default_time_dimension(dimensions: list[Dimension]) -> str | None: + for dimension in dimensions: + if dimension.type == "time": + return dimension.name + return None + + +def _find_default_grain(dimensions: list[Dimension]) -> str | None: + for dimension in dimensions: + if dimension.type == "time" and dimension.granularity in ( + "hour", + "day", + "week", + "month", + "quarter", + "year", + ): + return dimension.granularity + return None + + +def _props(node: TmdlNode) -> dict[str, Any]: + return {prop.name.lower(): prop.value for prop in node.properties} + + +def _string_prop(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return str(value) + + +def _resolve_expression_object(node: TmdlNode, props: dict[str, Any]) -> TmdlExpression | None: + expr_obj = _coerce_expression(node.default_property) + prop_expr = _coerce_expression(props.get("expression")) + if prop_expr is not None: + expr_obj = prop_expr + return expr_obj + + +def _resolve_expression(node: TmdlNode, props: dict[str, Any]) -> str | None: + expr_obj = _resolve_expression_object(node, props) + if expr_obj is None: + return None + return expr_obj.text + + +def _coerce_expression(value: Any) -> TmdlExpression | None: + if isinstance(value, TmdlExpression): + return value + if isinstance(value, str): + return TmdlExpression(text=value, is_block="\n" in value) + return None + + +def _dax_expression_for_export(obj: Any) -> TmdlExpression | None: + for attr in ("_tmdl_expression_obj", "_tmdl_expression", "_dax_expression", "dax"): + expression = _coerce_expression(getattr(obj, attr, None)) + if expression is not None and expression.text.strip(): + return expression + return None + + +def _is_true(value: Any) -> bool: + if value is True: + return True + if isinstance(value, str): + return value.lower() == "true" + return False + + +def _is_false(value: Any) -> bool: + if value is False: + return True + if isinstance(value, str): + return value.lower() == "false" + return False + + +def _map_data_type(data_type: str | None) -> tuple[str, str | None]: + if not data_type: + return "categorical", None + + dt = data_type.lower() + if "date" in dt or "time" in dt: + granularity = "day" if "date" in dt and "time" not in dt else "hour" + return "time", granularity + if "bool" in dt: + return "boolean", None + if any(token in dt for token in ("int", "decimal", "double", "numeric", "currency", "float")): + return "numeric", None + return "categorical", None + + +def _parse_dax_expression(expression: str, node: TmdlNode, context: str) -> Any | None: + try: + from sidemantic_dax.ast import parse_expression as parse_dax_expression + except Exception as exc: + raise DaxRuntimeUnavailableError( + "sidemantic_dax is required to parse embedded TMDL DAX. Install sidemantic[dax] and retry." + ) from exc + + try: + return parse_dax_expression(expression) + except RuntimeError as exc: + if "native module is not available" in str(exc): + raise DaxRuntimeUnavailableError( + "sidemantic_dax native module is not available. Rebuild or reinstall sidemantic[dax]." + ) from exc + raise ValueError(_format_dax_error(node, context, str(exc))) from exc + except Exception as exc: + raise ValueError(_format_dax_error(node, context, str(exc))) from exc + + +def _format_dax_error(node: TmdlNode, context: str, message: str) -> str: + name = node.name or "" + if node.location: + location = f"{node.location.file or ''}:{node.location.line}:{node.location.column}" + return f"DAX parse error in {context} {name} at {location}: {message}" + return f"DAX parse error in {context} {name}: {message}" + + +def _append_import_warning( + warnings: list[TmdlImportWarning], + node: TmdlNode, + *, + code: str, + context: str, + message: str, + model_name: str | None = None, +) -> None: + warning: TmdlImportWarning = { + "code": code, + "context": context, + "message": message, + "name": node.name or "", + } + if model_name: + warning["model"] = model_name + if node.location: + warning["file"] = node.location.file + warning["line"] = node.location.line + warning["column"] = node.location.column + warnings.append(warning) + + +def _append_dax_translation_warnings( + warnings: list[TmdlImportWarning], + node: TmdlNode, + *, + context: str, + model_name: str | None, + translation_warnings: list[dict[str, Any]], +) -> None: + for translation_warning in translation_warnings: + if not isinstance(translation_warning, dict): + continue + _append_import_warning( + warnings, + node, + code=str(translation_warning.get("code") or "dax_translation_warning"), + context=context, + message=str(translation_warning.get("message") or "DAX translation warning"), + model_name=model_name, + ) + + +def _append_export_warning( + warnings: list[TmdlExportWarning], + *, + code: str, + context: str, + message: str, + from_model: str | None = None, + to_model: str | None = None, +) -> None: + warning: TmdlExportWarning = { + "code": code, + "context": context, + "message": message, + } + if from_model: + warning["from_model"] = from_model + if to_model: + warning["to_model"] = to_model + warnings.append(warning) + + +def _extract_dax_agg( + expression: str, table_name: str, dax_expr: DaxExpr | None = None +) -> tuple[str | None, str | None]: + if dax_expr is not None: + parsed = _extract_dax_agg_from_ast(dax_expr, table_name) + if parsed is not None: + return parsed + expr = " ".join(part.strip() for part in expression.splitlines() if part.strip()) + if not expr: + return None, None + + match = _match_single_function(expr) + if not match: + return None, None + + func, arg = match + func_lower = func.lower() + agg = { + "sum": "sum", + "average": "avg", + "averagea": "avg", + "avg": "avg", + "min": "min", + "mina": "min", + "max": "max", + "maxa": "max", + "minx": "min", + "maxx": "max", + "median": "median", + "medianx": "median", + "count": "count", + "countrows": "count", + "counta": "count", + "countblank": "count", + "countx": "count", + "countax": "count", + "distinctcount": "count_distinct", + "distinctcountnoblank": "count_distinct", + "approximatedistinctcount": "count_distinct", + }.get(func_lower) + + if not agg: + return None, None + + if func_lower == "countrows": + return agg, None + + table, column = _parse_dax_column_ref(arg) + if not column: + return None, None + + if table and table.lower() == table_name.lower(): + return agg, column + if table: + return agg, f"{table}.{column}" + return agg, column + + +def _extract_dax_agg_from_ast(expr: Any, table_name: str) -> tuple[str | None, str | None] | None: + try: + from sidemantic_dax import ast as dax_ast + except Exception: + return None + + def unwrap(value: Any) -> Any: + while isinstance(value, dax_ast.Paren): + value = value.expr + return value + + expr = unwrap(expr) + if not isinstance(expr, dax_ast.FunctionCall): + return None + + func = expr.name.lower() + agg = { + "sum": "sum", + "average": "avg", + "averagea": "avg", + "avg": "avg", + "min": "min", + "mina": "min", + "max": "max", + "maxa": "max", + "minx": "min", + "maxx": "max", + "median": "median", + "medianx": "median", + "count": "count", + "countrows": "count", + "counta": "count", + "countblank": "count", + "countx": "count", + "countax": "count", + "distinctcount": "count_distinct", + "distinctcountnoblank": "count_distinct", + "approximatedistinctcount": "count_distinct", + }.get(func) + if not agg: + return None + + if func == "countrows": + return agg, None + + if len(expr.args) != 1: + return None + + arg = unwrap(expr.args[0]) + if isinstance(arg, dax_ast.TableColumnRef): + table = arg.table.name + column = arg.column + elif isinstance(arg, dax_ast.BracketRef): + table = None + column = arg.name + elif isinstance(arg, dax_ast.Identifier): + table = None + column = arg.name + else: + return None + + if table and table.lower() == table_name.lower(): + return agg, column + if table: + return agg, f"{table}.{column}" + return agg, column + + +def _match_single_function(expr: str) -> tuple[str, str] | None: + depth = 0 + func_name = [] + idx = 0 + while idx < len(expr) and (expr[idx].isalnum() or expr[idx] == "_"): + func_name.append(expr[idx]) + idx += 1 + if not func_name: + return None + while idx < len(expr) and expr[idx].isspace(): + idx += 1 + if idx >= len(expr) or expr[idx] != "(": + return None + depth = 1 + idx += 1 + arg_start = idx + while idx < len(expr): + char = expr[idx] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + arg = expr[arg_start:idx].strip() + rest = expr[idx + 1 :].strip() + if rest: + return None + return "".join(func_name), arg + idx += 1 + return None + + +def _parse_dax_column_ref(expression: str) -> tuple[str | None, str | None]: + expr = expression.strip() + if not expr: + return None, None + + if "[" in expr and "]" in expr: + table_part, column_part = expr.split("[", 1) + column = column_part.rstrip("]").strip() + table = table_part.strip() + table = _unquote_identifier(table) if table else None + column = _unquote_identifier(column) + return table, column + + if "." in expr: + parts = _split_unquoted(expr, ".") + if len(parts) == 2: + return _unquote_identifier(parts[0]), _unquote_identifier(parts[1]) + + return None, _unquote_identifier(expr) + + +def _parse_column_reference(value: str | None) -> tuple[str | None, str | None]: + if not value: + return None, None + raw = value.strip() + if not raw: + return None, None + + if "[" in raw and "]" in raw: + return _parse_dax_column_ref(raw) + + if "." in raw: + parts = _split_unquoted(raw, ".") + if len(parts) == 2: + return _unquote_identifier(parts[0]), _unquote_identifier(parts[1]) + + return None, None + + +def _split_unquoted(text: str, sep: str) -> list[str]: + parts: list[str] = [] + current: list[str] = [] + in_single = False + in_double = False + idx = 0 + while idx < len(text): + char = text[idx] + if not in_double and char == "'": + if in_single and idx + 1 < len(text) and text[idx + 1] == "'": + current.append("'") + idx += 2 + continue + in_single = not in_single + elif not in_single and char == '"': + if in_double and idx + 1 < len(text) and text[idx + 1] == '"': + current.append('"') + idx += 2 + continue + in_double = not in_double + elif not in_single and not in_double and char == sep: + parts.append("".join(current)) + current = [] + idx += 1 + continue + current.append(char) + idx += 1 + parts.append("".join(current)) + return [part.strip() for part in parts if part.strip()] + + +def _unquote_identifier(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + inner = value[1:-1] + if value[0] == "'": + return inner.replace("''", "'") + return inner.replace('""', '"') + return value + + +def _map_relationship_type(from_cardinality: str | None, to_cardinality: str | None) -> str | None: + from_card = (from_cardinality or "").lower() + to_card = (to_cardinality or "").lower() + if not from_card and not to_card: + return "many_to_one" + if from_card == "many" and not to_card: + return "many_to_one" + if not from_card and to_card == "one": + return "many_to_one" + if from_card == "one" and not to_card: + return "one_to_one" + if not from_card and to_card == "many": + return "one_to_many" + if from_card == "many" and to_card == "one": + return "many_to_one" + if from_card == "one" and to_card == "many": + return "one_to_many" + if from_card == "one" and to_card == "one": + return "one_to_one" + if from_card == "many" and to_card == "many": + return "many_to_many" + return None + + +def _export_database(graph: SemanticGraph, name: str) -> str: + database_name = getattr(graph, "_tmdl_database_name", None) or name + database_name_raw = getattr(graph, "_tmdl_database_name_raw", None) + model_name = getattr(graph, "_tmdl_database_model_name", None) or getattr(graph, "_tmdl_model_name", None) or name + model_name_raw = getattr(graph, "_tmdl_database_model_name_raw", None) or getattr( + graph, "_tmdl_model_name_raw", None + ) + + lines: list[str] = [] + leading_comments = getattr(graph, "_tmdl_database_leading_comments", None) + if isinstance(leading_comments, list): + for comment in leading_comments: + if isinstance(comment, str) and comment.strip(): + lines.append(comment) + database_description = getattr(graph, "_tmdl_database_description", None) + if isinstance(database_description, str) and database_description.strip(): + lines.extend(_format_description(database_description)) + lines.append(f"database {_format_identifier_with_raw(database_name, database_name_raw)}") + _export_passthrough_properties(lines, getattr(graph, "_tmdl_database_properties", None), set(), indent=" ") + lines.append(f" model {_format_identifier_with_raw(model_name, model_name_raw)}") + for node in _coerce_tmdl_nodes(getattr(graph, "_tmdl_database_child_nodes", None)): + lines.extend(_render_passthrough_node(node, indent=" ")) + return "\n".join(lines) + "\n" + + +def _export_model(graph: SemanticGraph, name: str) -> str: + model_name = getattr(graph, "_tmdl_model_name", None) or name + model_name_raw = getattr(graph, "_tmdl_model_name_raw", None) + lines: list[str] = [] + leading_comments = getattr(graph, "_tmdl_model_leading_comments", None) + if isinstance(leading_comments, list): + for comment in leading_comments: + if isinstance(comment, str) and comment.strip(): + lines.append(comment) + model_description = getattr(graph, "_tmdl_model_description", None) + if isinstance(model_description, str) and model_description.strip(): + lines.extend(_format_description(model_description)) + + lines.append(f"model {_format_identifier_with_raw(model_name, model_name_raw)}") + _export_passthrough_properties(lines, getattr(graph, "_tmdl_model_properties", None), set(), indent=" ") + for table_name, table_name_raw in _export_model_table_refs(graph): + lines.append(f" ref table {_format_identifier_with_raw(table_name, table_name_raw)}") + for rel_name, rel_name_raw in _export_relationship_refs(graph): + lines.append(f" ref relationship {_format_identifier_with_raw(rel_name, rel_name_raw)}") + for node in _coerce_tmdl_nodes(getattr(graph, "_tmdl_model_child_nodes", None)): + lines.extend(_render_passthrough_node(node, indent=" ")) + for node in _coerce_tmdl_nodes(getattr(graph, "_tmdl_root_nodes", None)): + lines.extend(_render_passthrough_node(node, indent="")) + return "\n".join(lines) + "\n" + + +def _export_table(model: Model) -> str: + lines: list[str] = [] + leading_comments = getattr(model, "_tmdl_leading_comments", None) + if isinstance(leading_comments, list): + for comment in leading_comments: + if isinstance(comment, str) and comment.strip(): + lines.append(comment) + raw_value_props = getattr(model, "_tmdl_raw_value_properties", None) + raw_description_value = _raw_value_for_key(raw_value_props, "description") + if model.description and raw_description_value is None: + lines.extend(_format_description(model.description)) + + node_type = str(getattr(model, "_tmdl_node_type", "table")).lower() + model_name_raw = getattr(model, "_tmdl_name_raw", None) + model_expression_obj = _dax_expression_for_export(model) + is_calculated_table = node_type == "calculatedtable" or ( + model_expression_obj is not None and getattr(model, "expression_language", None) == "dax" + ) + if is_calculated_table and model_expression_obj and model_expression_obj.text.strip(): + _append_expression_assignment( + lines, + f"calculatedTable {_format_identifier_with_raw(model.name, model_name_raw)}", + model_expression_obj, + block_indent=" ", + ) + else: + lines.append(f"table {_format_identifier_with_raw(model.name, model_name_raw)}") + emitted_keys: set[str] = set() + if raw_description_value is not None: + lines.append(f" description: {raw_description_value}") + emitted_keys.add("description") + elif model.description: + emitted_keys.add("description") + if is_calculated_table and model_expression_obj and model_expression_obj.text.strip(): + emitted_keys.add("expression") + _export_passthrough_properties(lines, getattr(model, "_tmdl_properties", None), emitted_keys, indent=" ") + + for dim in model.dimensions: + dim_lines = _export_dimension(model, dim) + lines.extend([" " + line for line in dim_lines]) + + for metric in model.metrics: + metric_lines = _export_metric(model, metric) + if metric_lines: + lines.extend([" " + line for line in metric_lines]) + + for node in _coerce_tmdl_nodes(getattr(model, "_tmdl_child_nodes", None)): + lines.extend(_render_passthrough_node(node, indent=" ")) + + return "\n".join(lines) + "\n" + + +def _export_dimension(model: Model, dim: Dimension) -> list[str]: + lines: list[str] = [] + leading_comments = getattr(dim, "_tmdl_leading_comments", None) + if isinstance(leading_comments, list): + for comment in leading_comments: + if isinstance(comment, str) and comment.strip(): + lines.append(comment.lstrip()) + dim_node_type = str(getattr(dim, "_tmdl_node_type", "")).lower() + expression_obj = _dax_expression_for_export(dim) + if dim_node_type == "calculatedcolumn": + kind = "calculatedColumn" + elif dim_node_type == "column": + kind = "column" + else: + kind = "column" + if expression_obj is not None or (dim.sql and not _is_simple_identifier(dim.sql)): + kind = "calculatedColumn" + + expression_sql = dim.sql + if kind == "calculatedColumn" and expression_obj and expression_obj.text.strip(): + expression_sql = expression_obj.text + + dim_name_raw = getattr(dim, "_tmdl_name_raw", None) + lines.append(f"{kind} {_format_identifier_with_raw(dim.name, dim_name_raw)}") + raw_value_props = getattr(dim, "_tmdl_raw_value_properties", None) + emitted_keys: set[str] = set() + + def _emit_data_type() -> None: + dim_data_type = getattr(dim, "_tmdl_data_type", None) + if isinstance(dim_data_type, str) and dim_data_type.strip(): + data_type_value = _raw_value_for_key(raw_value_props, "datatype") or dim_data_type + lines.append(f" dataType: {data_type_value}") + else: + lines.append(f" dataType: {_map_dimension_type(dim)}") + emitted_keys.add("datatype") + + def _emit_is_key() -> None: + raw_is_key = _raw_value_for_key(raw_value_props, "iskey") + if raw_is_key is not None: + lines.append(f" isKey: {raw_is_key}") + emitted_keys.add("iskey") + return + if dim.name == model.primary_key: + lines.append(" isKey") + emitted_keys.add("iskey") + + def _emit_is_hidden() -> None: + raw_is_hidden = _raw_value_for_key(raw_value_props, "ishidden") + if raw_is_hidden is not None: + lines.append(f" isHidden: {raw_is_hidden}") + emitted_keys.add("ishidden") + return + if not dim.public: + lines.append(" isHidden: true") + emitted_keys.add("ishidden") + + def _emit_caption() -> None: + if not dim.label: + return + caption_value = _raw_value_for_key(raw_value_props, "caption") or _format_string(dim.label) + lines.append(f" caption: {caption_value}") + emitted_keys.add("caption") + + def _emit_format() -> None: + if not dim.format: + return + format_value = _raw_value_for_key(raw_value_props, "formatstring") or _format_string(dim.format) + lines.append(f" formatString: {format_value}") + emitted_keys.add("formatstring") + + def _emit_description() -> None: + if not dim.description: + return + description_value = _raw_value_for_key(raw_value_props, "description") or _format_string(dim.description) + lines.append(f" description: {description_value}") + emitted_keys.add("description") + + def _emit_source_or_expression() -> None: + if not expression_sql: + return + raw_source_column = _raw_value_for_key(raw_value_props, "sourcecolumn") + if kind == "column" and (raw_source_column is not None or _is_simple_identifier(expression_sql)): + source_column_value = raw_source_column or _format_value(expression_sql) + lines.append(f" sourceColumn: {source_column_value}") + emitted_keys.update({"sourcecolumn", "expression"}) + return + if expression_obj is not None: + _append_expression_assignment( + lines, + " expression", + expression_obj, + block_indent=" ", + ) + elif "\n" in expression_sql: + lines.append(" expression =") + for expr_line in expression_sql.splitlines(): + lines.append(f" {expr_line}") + else: + lines.append(f" expression = {expression_sql}") + emitted_keys.update({"sourcecolumn", "expression"}) + + emitters = { + "datatype": _emit_data_type, + "iskey": _emit_is_key, + "ishidden": _emit_is_hidden, + "caption": _emit_caption, + "formatstring": _emit_format, + "description": _emit_description, + "sourcecolumn": _emit_source_or_expression, + "expression": _emit_source_or_expression, + } + default_order = [ + "datatype", + "iskey", + "ishidden", + "caption", + "formatstring", + "description", + "sourcecolumn", + "expression", + ] + preferred_order = getattr(dim, "_tmdl_property_order", None) + if not isinstance(preferred_order, list): + preferred_order = [] + for key in preferred_order: + key_l = str(key).lower() + if key_l in emitted_keys: + continue + emitter = emitters.get(key_l) + if emitter is not None: + emitter() + for key in default_order: + if key in emitted_keys: + continue + emitter = emitters.get(key) + if emitter is not None: + emitter() + _export_passthrough_properties(lines, getattr(dim, "_tmdl_properties", None), emitted_keys, indent=" ") + for node in _coerce_tmdl_nodes(getattr(dim, "_tmdl_child_nodes", None)): + lines.extend(_render_passthrough_node(node, indent=" ")) + return lines + + +def _export_metric(model: Model, metric: Metric) -> list[str] | None: + expression_obj = _dax_expression_for_export(metric) + if expression_obj and expression_obj.text.strip(): + expression = expression_obj.text + else: + expression = _metric_to_dax(metric, model.name) + expression_obj = _coerce_expression(expression) + if not expression_obj or not expression_obj.text: + return None + + lines: list[str] = [] + leading_comments = getattr(metric, "_tmdl_leading_comments", None) + if isinstance(leading_comments, list): + for comment in leading_comments: + if isinstance(comment, str) and comment.strip(): + lines.append(comment.lstrip()) + raw_value_props = getattr(metric, "_tmdl_raw_value_properties", None) + raw_description_value = _raw_value_for_key(raw_value_props, "description") + emitted_keys: set[str] = {"expression"} + if metric.description and raw_description_value is None: + lines.extend(_format_description(metric.description)) + emitted_keys.add("description") + metric_name_raw = getattr(metric, "_tmdl_name_raw", None) + _append_expression_assignment( + lines, + f"measure {_format_identifier_with_raw(metric.name, metric_name_raw)}", + expression_obj, + block_indent=" ", + ) + + def _emit_description() -> None: + if raw_description_value is None: + return + lines.append(f" description: {raw_description_value}") + emitted_keys.add("description") + + def _emit_caption() -> None: + if not metric.label: + return + caption_value = _raw_value_for_key(raw_value_props, "caption") or _format_string(metric.label) + lines.append(f" caption: {caption_value}") + emitted_keys.add("caption") + + def _emit_format() -> None: + if not metric.format: + return + format_value = _raw_value_for_key(raw_value_props, "formatstring") or _format_string(metric.format) + lines.append(f" formatString: {format_value}") + emitted_keys.add("formatstring") + + def _emit_is_hidden() -> None: + raw_is_hidden = _raw_value_for_key(raw_value_props, "ishidden") + if raw_is_hidden is not None: + lines.append(f" isHidden: {raw_is_hidden}") + emitted_keys.add("ishidden") + return + if not metric.public: + lines.append(" isHidden: true") + emitted_keys.add("ishidden") + + emitters = { + "description": _emit_description, + "caption": _emit_caption, + "formatstring": _emit_format, + "ishidden": _emit_is_hidden, + } + preferred_order = getattr(metric, "_tmdl_property_order", None) + if not isinstance(preferred_order, list): + preferred_order = [] + for key in preferred_order: + key_l = str(key).lower() + if key_l in emitted_keys: + continue + emitter = emitters.get(key_l) + if emitter is not None: + emitter() + for key in ("description", "caption", "formatstring", "ishidden"): + if key in emitted_keys: + continue + emitter = emitters.get(key) + if emitter is not None: + emitter() + + _export_passthrough_properties(lines, getattr(metric, "_tmdl_properties", None), emitted_keys, indent=" ") + for node in _coerce_tmdl_nodes(getattr(metric, "_tmdl_child_nodes", None)): + lines.extend(_render_passthrough_node(node, indent=" ")) + return lines + + +def _export_relationships(graph: SemanticGraph, warnings: list[TmdlExportWarning] | None = None) -> str: + lines: list[str] = [] + for model in graph.models.values(): + for rel in model.relationships: + related = graph.models.get(rel.name) + if not related: + if warnings is not None: + _append_export_warning( + warnings, + code="relationship_export_skip", + context="relationship", + message=( + f"Skipping relationship export: related model not found from='{model.name}' to='{rel.name}'" + ), + from_model=model.name, + to_model=rel.name, + ) + continue + + if rel.type == "many_to_one": + from_table = model.name + to_table = related.name + from_column = rel.foreign_key or rel.sql_expr + to_column = rel.primary_key or related.primary_key + from_card, to_card = "many", "one" + elif rel.type in ("one_to_many", "one_to_one"): + from_table = model.name + to_table = related.name + from_column = _relationship_tmdl_from_column(rel) or model.primary_key + to_column = rel.foreign_key or rel.sql_expr + from_card = "one" + to_card = "many" if rel.type == "one_to_many" else "one" + elif rel.type == "many_to_many": + from_table = model.name + to_table = related.name + from_column = rel.foreign_key or rel.sql_expr + to_column = rel.primary_key or related.primary_key + from_card, to_card = "many", "many" + else: + if warnings is not None: + _append_export_warning( + warnings, + code="relationship_export_skip", + context="relationship", + message=f"Skipping relationship export: unsupported relationship type '{rel.type}'", + from_model=model.name, + to_model=related.name, + ) + continue + + rel_name = _relationship_export_name(from_table, to_table, rel) + leading_comments = getattr(rel, "_tmdl_leading_comments", None) + if isinstance(leading_comments, list): + for comment in leading_comments: + if isinstance(comment, str) and comment.strip(): + lines.append(comment.lstrip()) + rel_description = getattr(rel, "_tmdl_description", None) + if isinstance(rel_description, str) and rel_description.strip(): + lines.extend(_format_description(rel_description)) + raw_value_props = getattr(rel, "_tmdl_raw_value_properties", None) + rel_name_raw = getattr(rel, "_tmdl_relationship_name_raw", None) + lines.append(f"relationship {_format_identifier_with_raw(rel_name, rel_name_raw)}") + from_column_value = _raw_value_for_key(raw_value_props, "fromcolumn") or _format_column_ref( + from_table, + from_column, + ) + to_column_value = _raw_value_for_key(raw_value_props, "tocolumn") or _format_column_ref( + to_table, + to_column, + ) + from_cardinality_value = _raw_value_for_key(raw_value_props, "fromcardinality") or from_card + to_cardinality_value = _raw_value_for_key(raw_value_props, "tocardinality") or to_card + emitted_keys: set[str] = set() + + def _emit_from_column() -> None: + lines.append(f" fromColumn: {from_column_value}") + emitted_keys.add("fromcolumn") + + def _emit_to_column() -> None: + lines.append(f" toColumn: {to_column_value}") + emitted_keys.add("tocolumn") + + def _emit_from_cardinality() -> None: + lines.append(f" fromCardinality: {from_cardinality_value}") + emitted_keys.add("fromcardinality") + + def _emit_to_cardinality() -> None: + lines.append(f" toCardinality: {to_cardinality_value}") + emitted_keys.add("tocardinality") + + def _emit_is_active() -> None: + raw_is_active = _raw_value_for_key(raw_value_props, "isactive") + if raw_is_active is not None: + lines.append(f" isActive: {raw_is_active}") + emitted_keys.add("isactive") + return + if getattr(rel, "_tmdl_is_active_explicit", False): + lines.append(" isActive") + emitted_keys.add("isactive") + return + if not rel.active: + lines.append(" isActive: false") + emitted_keys.add("isactive") + + emitters = { + "fromcolumn": _emit_from_column, + "tocolumn": _emit_to_column, + "fromcardinality": _emit_from_cardinality, + "tocardinality": _emit_to_cardinality, + "isactive": _emit_is_active, + } + preferred_order = getattr(rel, "_tmdl_property_order", None) + if not isinstance(preferred_order, list): + preferred_order = [] + for key in preferred_order: + key_l = str(key).lower() + if key_l in emitted_keys: + continue + emitter = emitters.get(key_l) + if emitter is not None: + emitter() + for key in ("fromcolumn", "tocolumn", "fromcardinality", "tocardinality", "isactive"): + if key in emitted_keys: + continue + emitter = emitters.get(key) + if emitter is not None: + emitter() + _export_relationship_passthrough_properties(lines, rel, emitted_keys) + for node in _coerce_tmdl_nodes(getattr(rel, "_tmdl_child_nodes", None)): + lines.extend(_render_passthrough_node(node, indent=" ")) + lines.append("") + + if not lines: + return "" + if lines[-1] == "": + lines = lines[:-1] + return "\n".join(lines) + "\n" + + +def _export_relationship_passthrough_properties(lines: list[str], rel: Relationship, emitted_keys: set[str]) -> None: + _export_passthrough_properties( + lines, getattr(rel, "_tmdl_relationship_properties", None), emitted_keys, indent=" " + ) + + +def _export_passthrough_properties( + lines: list[str], + passthrough_props: Any, + emitted_keys: set[str], + *, + indent: str, +) -> None: + if not isinstance(passthrough_props, list): + return + + for prop in passthrough_props: + if not isinstance(prop, dict): + continue + name = prop.get("name") + if not isinstance(name, str) or not name.strip(): + continue + key = name.lower() + if key in emitted_keys: + continue + + kind = str(prop.get("kind") or "value").lower() + value = prop.get("value") + if kind == "expression": + expression_obj = _coerce_expression(value) or TmdlExpression(text=str(value or "")) + _append_expression_assignment(lines, f"{indent}{name}", expression_obj, block_indent=f"{indent} ") + else: + raw_value = prop.get("raw") + if isinstance(raw_value, str): + lines.append(f"{indent}{name}: {raw_value}") + else: + lines.append(f"{indent}{name}: {_format_value(value)}") + emitted_keys.add(key) + + +def _append_expression_assignment(lines: list[str], lhs: str, expression: TmdlExpression, *, block_indent: str) -> None: + meta = _format_expression_meta(expression) + is_block = expression.is_block or "\n" in expression.text + if is_block: + if expression.block_delimiter == "```" and not meta: + lines.append(f"{lhs} = ```") + for expr_line in expression.text.splitlines(): + lines.append(f"{block_indent}{expr_line}") + lines.append(f"{block_indent}```") + return + lines.append(f"{lhs} ={meta}") + for expr_line in expression.text.splitlines(): + lines.append(f"{block_indent}{expr_line}") + return + lines.append(f"{lhs} = {expression.text}{meta}") + + +def _format_expression_meta(expression: TmdlExpression) -> str: + if isinstance(expression.meta_raw, str): + return f" meta [{expression.meta_raw}]" + if not expression.meta: + return "" + parts = [f"{key}={_format_value(value)}" for key, value in expression.meta.items()] + return f" meta [{', '.join(parts)}]" + + +def _coerce_tmdl_nodes(value: Any) -> list[TmdlNode]: + if not isinstance(value, list): + return [] + nodes: list[TmdlNode] = [] + for item in value: + if isinstance(item, TmdlNode): + nodes.append(item) + return nodes + + +def _render_passthrough_node(node: TmdlNode, *, indent: str) -> list[str]: + lines: list[str] = [] + for comment in node.leading_comments: + if isinstance(comment, str) and comment.strip(): + lines.append(f"{indent}{comment}") + if node.description: + for desc_line in _format_description(node.description): + lines.append(f"{indent}{desc_line}") + + declaration = _render_node_declaration(node) + expr = _coerce_expression(node.default_property) + if expr is not None: + _append_expression_assignment(lines, f"{indent}{declaration}", expr, block_indent=f"{indent} ") + else: + lines.append(f"{indent}{declaration}") + + for prop in node.properties: + _render_passthrough_property(lines, prop, indent=f"{indent} ") + for child in node.children: + lines.extend(_render_passthrough_node(child, indent=f"{indent} ")) + return lines + + +def _render_node_declaration(node: TmdlNode) -> str: + parts: list[str] = [] + if node.is_ref: + parts.append("ref") + parts.append(node.type) + if node.name is not None: + parts.append(_format_identifier_with_raw(node.name, node.name_raw)) + return " ".join(parts) + + +def _render_passthrough_property(lines: list[str], prop: TmdlProperty, *, indent: str) -> None: + if prop.kind == "expression": + expression_obj = _coerce_expression(prop.value) or TmdlExpression(text=str(prop.value or "")) + _append_expression_assignment(lines, f"{indent}{prop.name}", expression_obj, block_indent=f"{indent} ") + return + if isinstance(prop.raw, str): + lines.append(f"{indent}{prop.name}: {prop.raw}") + return + lines.append(f"{indent}{prop.name}: {_format_value(prop.value)}") + + +def _raw_value_for_key(raw_props: Any, key: str) -> str | None: + if not isinstance(raw_props, dict): + return None + value = raw_props.get(key.lower()) + if isinstance(value, str): + return value + return None + + +def _export_model_table_refs(graph: SemanticGraph) -> list[tuple[str, str | None]]: + refs_by_key: dict[str, tuple[str, str | None]] = {} + for model in graph.models.values(): + refs_by_key[model.name.lower()] = (model.name, getattr(model, "_tmdl_name_raw", None)) + + ordered: list[tuple[str, str | None]] = [] + seen: set[str] = set() + source_refs = getattr(graph, "_tmdl_model_table_refs", None) + if isinstance(source_refs, list): + for ref in source_refs: + if not isinstance(ref, (tuple, list)) or len(ref) != 2: + continue + name_value, raw_value = ref + if not isinstance(name_value, str) or not name_value.strip(): + continue + key = name_value.lower() + current = refs_by_key.get(key) + if current is None or key in seen: + continue + preserved_raw = raw_value if isinstance(raw_value, str) else current[1] + ordered.append((current[0], preserved_raw)) + seen.add(key) + + for model in graph.models.values(): + key = model.name.lower() + if key in seen: + continue + ordered.append(refs_by_key[key]) + seen.add(key) + return ordered + + +def _export_relationship_refs(graph: SemanticGraph) -> list[tuple[str, str | None]]: + refs_by_key: dict[str, tuple[str, str | None]] = {} + for model in graph.models.values(): + for rel in model.relationships: + related = graph.models.get(rel.name) + if not related: + continue + rel_name = _relationship_export_name(model.name, related.name, rel) + rel_name_raw = getattr(rel, "_tmdl_relationship_name_raw", None) + key = rel_name.lower() + existing = refs_by_key.get(key) + if existing is None: + refs_by_key[key] = (rel_name, rel_name_raw) + elif existing[1] is None and rel_name_raw is not None: + refs_by_key[key] = (existing[0], rel_name_raw) + + ordered: list[tuple[str, str | None]] = [] + seen: set[str] = set() + source_refs = getattr(graph, "_tmdl_model_relationship_refs", None) + if isinstance(source_refs, list): + for ref in source_refs: + if not isinstance(ref, (tuple, list)) or len(ref) != 2: + continue + name_value, raw_value = ref + if not isinstance(name_value, str) or not name_value.strip(): + continue + key = name_value.lower() + current = refs_by_key.get(key) + if current is None or key in seen: + continue + preserved_raw = raw_value if isinstance(raw_value, str) else current[1] + ordered.append((current[0], preserved_raw)) + seen.add(key) + + for key in sorted(refs_by_key): + if key in seen: + continue + ordered.append(refs_by_key[key]) + seen.add(key) + return ordered + + +def _relationship_name(from_table: str, to_table: str) -> str: + return f"{from_table}_{to_table}" + + +def _relationship_tmdl_from_column(rel: Relationship) -> str | None: + value = getattr(rel, "_tmdl_from_column", None) + if isinstance(value, str) and value.strip(): + return value + return None + + +def _relationship_export_name(from_table: str, to_table: str, rel: Relationship) -> str: + explicit_name = getattr(rel, "_tmdl_relationship_name", None) + if isinstance(explicit_name, str) and explicit_name.strip(): + return explicit_name + return _relationship_name(from_table, to_table) + + +def _metric_to_dax(metric: Metric, table_name: str) -> str | None: + if metric.type == "derived" and metric.sql: + return metric.sql + + if metric.agg: + func = { + "sum": "SUM", + "avg": "AVERAGE", + "min": "MIN", + "max": "MAX", + "count": "COUNT", + "count_distinct": "DISTINCTCOUNT", + "median": "MEDIAN", + }.get(metric.agg) + if not func: + return metric.sql + + if metric.agg == "count" and not metric.sql: + return f"COUNTROWS({_format_identifier(table_name)})" + + if metric.sql: + if _is_simple_identifier(metric.sql): + return f"{func}({_format_identifier(table_name)}[{_format_identifier(metric.sql)}])" + return f"{func}({metric.sql})" + + return metric.sql + + +def _is_simple_identifier(value: str) -> bool: + if not value: + return False + return value.replace("_", "").isalnum() + + +def _map_dimension_type(dimension: Dimension) -> str: + if dimension.type == "time": + if dimension.granularity == "day": + return "date" + return "dateTime" + if dimension.type == "numeric": + return "double" + if dimension.type == "boolean": + return "boolean" + return "string" + + +def _format_description(text: str) -> list[str]: + return [f"/// {line}" if line else "///" for line in text.splitlines()] + + +def _format_identifier(name: str) -> str: + if not name: + return "''" + if _is_simple_identifier(name): + return name + return _format_string(name) + + +def _format_identifier_with_raw(name: str, raw_name: Any) -> str: + if isinstance(raw_name, str) and raw_name.strip(): + return raw_name.strip() + return _format_identifier(name) + + +def _format_string(value: str) -> str: + escaped = value.replace("'", "''") + return f"'{escaped}'" + + +def _format_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "null" + if isinstance(value, (int, float)): + return str(value) + + text = str(value) + table_ref, column_ref = _parse_column_reference(text) + if table_ref and column_ref: + return _format_column_ref(table_ref, column_ref) + if _is_simple_identifier(text): + return text + escaped = text.replace('"', '""') + return f'"{escaped}"' + + +def _format_column_ref(table: str, column: str | None) -> str: + column_value = column or "id" + return f"{_format_identifier(table)}[{_format_identifier(column_value)}]" + + +def _safe_filename(name: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in ("_", "-", ".") else "_" for ch in name) + return cleaned.strip("_") or "table" + + +def _export_script(graph: SemanticGraph, name: str, warnings: list[TmdlExportWarning] | None = None) -> str: + lines = ["createOrReplace"] + database_lines = _export_database(graph, name).splitlines() + lines.extend([" " + line for line in database_lines if line.strip()]) + + model_lines = _export_model(graph, name).splitlines() + lines.extend([" " + line for line in model_lines if line.strip()]) + + for model in graph.models.values(): + table_lines = _export_table(model).splitlines() + lines.extend([" " + line for line in table_lines if line.strip()]) + relationships = _export_relationships(graph, warnings).splitlines() + if relationships: + lines.extend([" " + line for line in relationships if line.strip()]) + return "\n".join(lines) + "\n" diff --git a/sidemantic/adapters/tmdl_parser.py b/sidemantic/adapters/tmdl_parser.py new file mode 100644 index 00000000..1fda2497 --- /dev/null +++ b/sidemantic/adapters/tmdl_parser.py @@ -0,0 +1,772 @@ +"""Parser for Power BI Tabular Model Definition Language (TMDL).""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class TmdlLocation: + file: str | None + line: int + column: int + + +@dataclass +class TmdlExpression: + text: str + meta: dict[str, Any] | None = None + meta_raw: str | None = field(default=None, compare=False) + is_block: bool = False + block_delimiter: str | None = field(default=None, compare=False) + + +@dataclass +class TmdlProperty: + name: str + value: Any + kind: str + raw: str | None = field(default=None, compare=False) + + +@dataclass +class TmdlNode: + type: str + name: str | None + name_raw: str | None = field(default=None, compare=False) + is_ref: bool = False + properties: list[TmdlProperty] = field(default_factory=list) + children: list[TmdlNode] = field(default_factory=list) + default_property: TmdlExpression | None = None + description: str | None = None + leading_comments: list[str] = field(default_factory=list, compare=False) + location: TmdlLocation | None = None + + def property(self, name: str) -> TmdlProperty | None: + for prop in self.properties: + if prop.name == name: + return prop + return None + + def property_value(self, name: str) -> Any: + prop = self.property(name) + return prop.value if prop else None + + def child_nodes(self, type_name: str) -> list[TmdlNode]: + return [child for child in self.children if child.type == type_name] + + +@dataclass +class TmdlDocument: + nodes: list[TmdlNode] + file: str | None = None + + +class TmdlParseError(ValueError): + def __init__(self, message: str, location: TmdlLocation | None = None): + if location: + super().__init__(f"{message} ({location.file or ''}:{location.line}:{location.column})") + else: + super().__init__(message) + self.location = location + + +@dataclass +class _IndentConfig: + kind: str + width: int + + +@dataclass +class _LineInfo: + raw: str + content: str + indent: int + indent_width: int + lineno: int + is_blank: bool + is_comment: bool + is_description: bool + + +_IDENTIFIER_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" + + +class TmdlParser: + def parse(self, text: str, file: str | None = None) -> TmdlDocument: + lines, indent_config = _prepare_lines(text, file) + parser = _TmdlParser(lines, indent_config, file) + nodes = parser.parse_nodes(0) + return TmdlDocument(nodes=nodes, file=file) + + +class _TmdlParser: + def __init__(self, lines: list[_LineInfo], indent_config: _IndentConfig | None, file: str | None): + self.lines = lines + self.index = 0 + self.indent_config = indent_config + self.file = file + + def parse_nodes(self, indent_level: int) -> list[TmdlNode]: + nodes: list[TmdlNode] = [] + pending_description: str | None = None + pending_comments: list[str] = [] + + while self.index < len(self.lines): + line = self.lines[self.index] + + if line.is_blank: + pending_description = None + pending_comments = [] + self.index += 1 + continue + + if line.indent < indent_level: + break + + if line.indent > indent_level: + raise self._error("Unexpected indentation", line) + + if line.is_comment: + pending_comments.append(line.content) + self.index += 1 + continue + + if line.is_description: + pending_description = self._collect_description(indent_level) + continue + + node = self._parse_object(indent_level, pending_description, pending_comments) + pending_description = None + pending_comments = [] + nodes.append(node) + + return nodes + + def _collect_description(self, indent_level: int) -> str: + parts: list[str] = [] + while self.index < len(self.lines): + line = self.lines[self.index] + if line.is_description and line.indent == indent_level: + parts.append(line.content[3:].lstrip()) + self.index += 1 + continue + break + return "\n".join(parts) + + def _parse_object(self, indent_level: int, description: str | None, leading_comments: list[str]) -> TmdlNode: + line = self.lines[self.index] + is_ref, obj_type, name, name_raw, expr_text, expr_meta, expr_meta_raw = _parse_object_declaration( + line, + self.file, + ) + node = TmdlNode( + type=obj_type, + name=name, + name_raw=name_raw, + is_ref=is_ref, + description=description, + leading_comments=list(leading_comments), + location=TmdlLocation(self.file, line.lineno, line.indent_width + 1), + ) + self.index += 1 + + if expr_text is not None: + node.default_property = self._parse_expression_from_inline( + expr_text, + indent_level, + expr_meta, + expr_meta_raw, + stop_before_trailing_properties=True, + ) + if obj_type.lower() == "expression" and expr_text.strip().lower() in {"m", "dax"}: + body_expression = self._parse_expression_block(indent_level, stop_before_trailing_properties=True) + if body_expression.text: + node.default_property = body_expression + + properties, children = self._parse_object_body(indent_level + 1) + node.properties = properties + node.children = children + return node + + def _has_nested_body(self, indent_level: int) -> bool: + line = self.lines[self.index] + if _split_property_line(line.content)[1] is not None: + return False + + next_index = self.index + 1 + while next_index < len(self.lines): + next_line = self.lines[next_index] + if next_line.is_blank or next_line.is_comment or next_line.is_description: + next_index += 1 + continue + return next_line.indent > indent_level + return False + + def _parse_object_body(self, indent_level: int) -> tuple[list[TmdlProperty], list[TmdlNode]]: + properties: list[TmdlProperty] = [] + children: list[TmdlNode] = [] + pending_description: str | None = None + pending_comments: list[str] = [] + + while self.index < len(self.lines): + line = self.lines[self.index] + + if line.is_blank: + pending_description = None + pending_comments = [] + self.index += 1 + continue + + if line.indent < indent_level: + break + + if line.indent > indent_level: + raise self._error("Unexpected indentation", line) + + if line.is_comment: + pending_comments.append(line.content) + self.index += 1 + continue + + if line.is_description: + pending_description = self._collect_description(indent_level) + continue + + if _is_object_declaration(line.content) or self._has_nested_body(indent_level): + child = self._parse_object(indent_level, pending_description, pending_comments) + children.append(child) + pending_description = None + pending_comments = [] + continue + + properties.append(self._parse_property(indent_level)) + pending_description = None + pending_comments = [] + + return properties, children + + def _parse_property(self, indent_level: int) -> TmdlProperty: + line = self.lines[self.index] + name, sep, remainder = _split_property_line(line.content) + self.index += 1 + + if sep is None: + return TmdlProperty(name=name, value=True, kind="value") + + if sep == ":": + value = _parse_value(remainder) + return TmdlProperty(name=name, value=value, kind="value", raw=remainder) + + expr_text, meta, meta_raw = _split_meta(remainder) + expression = self._parse_expression_from_inline(expr_text, indent_level, meta, meta_raw) + return TmdlProperty(name=name, value=expression, kind="expression") + + def _parse_expression_from_inline( + self, + expr_text: str, + base_indent: int, + meta: dict[str, Any] | None, + meta_raw: str | None = None, + stop_before_trailing_properties: bool = False, + ) -> TmdlExpression: + expr_text = expr_text.strip() + if expr_text == "": + expression = self._parse_expression_block(base_indent, stop_before_trailing_properties) + elif expr_text == "```": + expression = self._parse_backtick_block(opening_consumed=True) + elif expr_text.startswith("```") and expr_text.endswith("```") and len(expr_text) > 6: + expression = TmdlExpression(text=expr_text[3:-3], is_block=False) + else: + expression = TmdlExpression(text=expr_text, is_block=False) + + if meta is not None: + expression.meta = meta + if meta_raw is not None: + expression.meta_raw = meta_raw + return expression + + def _parse_expression_block( + self, base_indent: int, stop_before_trailing_properties: bool = False + ) -> TmdlExpression: + if self.index >= len(self.lines): + raise TmdlParseError("Expected expression block", None) + + while self.index < len(self.lines) and self.lines[self.index].is_blank: + self.index += 1 + if self.index >= len(self.lines): + raise TmdlParseError("Expected expression block", None) + + first = self.lines[self.index] + if first.content.strip() == "```": + return self._parse_backtick_block(opening_consumed=False) + + expression_lines: list[str] = [] + while self.index < len(self.lines): + line = self.lines[self.index] + if line.indent <= base_indent: + break + if ( + stop_before_trailing_properties + and expression_lines + and line.indent == base_indent + 1 + and _looks_like_block_trailing_property(line.content) + ): + break + expression_lines.append(_strip_indent(line.raw, base_indent + 1, self.indent_config)) + self.index += 1 + + if not expression_lines: + raise self._error("Expected expression block", first) + + return TmdlExpression(text="\n".join(expression_lines), is_block=True) + + def _parse_backtick_block(self, opening_consumed: bool) -> TmdlExpression: + opening_line: _LineInfo | None = None + if opening_consumed: + if self.index > 0: + opening_line = self.lines[self.index - 1] + else: + if self.index >= len(self.lines): + raise TmdlParseError("Expected expression block", None) + opening_line = self.lines[self.index] + self.index += 1 + block_lines: list[str] = [] + while self.index < len(self.lines): + current = self.lines[self.index] + if current.content.strip() == "```": + self.index += 1 + return TmdlExpression( + text="\n".join(_strip_common_indent(block_lines)), + is_block=True, + block_delimiter="```", + ) + block_lines.append(current.raw) + self.index += 1 + + if opening_line is not None: + raise self._error("Unterminated backtick expression block", opening_line) + raise TmdlParseError("Unterminated backtick expression block", None) + + def _error(self, message: str, line: _LineInfo) -> TmdlParseError: + return TmdlParseError(message, TmdlLocation(self.file, line.lineno, line.indent_width + 1)) + + +def _prepare_lines(text: str, file: str | None) -> tuple[list[_LineInfo], _IndentConfig | None]: + raw_lines = text.splitlines() + indent_config = _detect_indent(raw_lines) + lines: list[_LineInfo] = [] + + for lineno, raw in enumerate(raw_lines, start=1): + if lineno == 1: + raw = raw.lstrip("\ufeff") + stripped, indent_width = _split_indent(raw) + indent = _indent_level(indent_width, indent_config, raw, file, lineno) + content = stripped + is_blank = content.strip() == "" + is_comment = content.startswith("//") or (content.startswith("#") and not content.startswith("###")) + is_description = content.startswith("///") + lines.append( + _LineInfo( + raw=raw, + content=content, + indent=indent, + indent_width=indent_width, + lineno=lineno, + is_blank=is_blank, + is_comment=is_comment and not is_description, + is_description=is_description, + ) + ) + + return lines, indent_config + + +def _detect_indent(lines: list[str]) -> _IndentConfig | None: + for raw in lines: + raw = raw.lstrip("\ufeff") + stripped, indent_width = _split_indent(raw) + if indent_width == 0: + continue + if stripped.strip() == "": + continue + if raw[:indent_width].count("\t") and raw[:indent_width].count(" "): + raise TmdlParseError("Mixed tabs and spaces in indentation", None) + if "\t" in raw[:indent_width]: + return _IndentConfig(kind="tabs", width=1) + return _IndentConfig(kind="spaces", width=indent_width) + return None + + +def _split_indent(raw: str) -> tuple[str, int]: + stripped = raw.lstrip(" \t") + return stripped, len(raw) - len(stripped) + + +def _indent_level(indent_width: int, config: _IndentConfig | None, raw: str, file: str | None, lineno: int) -> int: + if indent_width == 0: + return 0 + if config is None: + return 0 + leading = raw[:indent_width] + if config.kind == "tabs": + tab_count = len(leading) - len(leading.lstrip("\t")) + if tab_count: + return tab_count + return max(1, (indent_width + 3) // 4) + + if "\t" in leading: + indent_width = len(leading.expandtabs(config.width)) + return max(1, (indent_width + config.width - 1) // config.width) + + +def _is_object_declaration(content: str) -> bool: + stripped = content.strip() + if stripped.lower().startswith("createorreplace"): + return True + if stripped.lower().startswith("ref "): + return True + + first, remainder = _split_first_token(stripped) + if ":" in first or "=" in first: + return False + if not remainder: + return False + remainder = remainder.lstrip() + if not remainder: + return False + if remainder.startswith(":") or remainder.startswith("="): + return False + return True + + +def _looks_like_block_trailing_property(content: str) -> bool: + name, sep, _remainder = _split_property_line(content) + return sep == ":" and bool(name.strip()) + + +def _parse_object_declaration( + line: _LineInfo, file: str | None +) -> tuple[bool, str, str | None, str | None, str | None, dict[str, Any] | None, str | None]: + content = line.content.strip() + is_ref = False + if content.lower().startswith("ref "): + is_ref = True + content = content[4:].lstrip() + + obj_type, remainder = _split_first_token(content) + if obj_type == "": + raise TmdlParseError("Missing object type", TmdlLocation(file, line.lineno, line.indent_width + 1)) + + if obj_type.lower() == "createorreplace": + return is_ref, obj_type, None, None, None, None, None + + name, remainder, name_raw = _parse_identifier(remainder.strip()) + if not name: + if remainder.strip() == "": + return is_ref, obj_type, None, None, None, None, None + raise TmdlParseError("Missing object name", TmdlLocation(file, line.lineno, line.indent_width + 1)) + + expr_text = None + expr_meta = None + expr_meta_raw = None + + if remainder: + eq_index = _find_unquoted(remainder, "=") + if eq_index != -1: + expr_raw = remainder[eq_index + 1 :].strip() + expr_text, expr_meta, expr_meta_raw = _split_meta(expr_raw) + elif remainder.strip(): + raise TmdlParseError( + "Unexpected tokens after object name", TmdlLocation(file, line.lineno, line.indent_width + 1) + ) + + return is_ref, obj_type, name, name_raw, expr_text, expr_meta, expr_meta_raw + + +def _split_first_token(text: str) -> tuple[str, str]: + text = text.lstrip() + if not text: + return "", "" + for i, ch in enumerate(text): + if ch.isspace(): + return text[:i], text[i:] + return text, "" + + +def _parse_identifier(text: str) -> tuple[str, str, str]: + text = text.lstrip() + if not text: + return "", "", "" + + if text[0] in ("'", '"'): + token, remainder, raw = _parse_quoted(text) + return token, remainder, raw + + token = [] + for i, ch in enumerate(text): + if ch.isspace(): + return "".join(token), text[i:], text[:i] + token.append(ch) + return "".join(token), "", text + + +def _parse_quoted(text: str) -> tuple[str, str, str]: + quote = text[0] + token = [] + idx = 1 + while idx < len(text): + char = text[idx] + if char == quote: + if idx + 1 < len(text) and text[idx + 1] == quote: + token.append(quote) + idx += 2 + continue + return "".join(token), text[idx + 1 :], text[: idx + 1] + token.append(char) + idx += 1 + return "".join(token), "", text + + +def _split_property_line(content: str) -> tuple[str, str | None, str]: + content = content.strip() + sep_index = _find_unquoted(content, ":=") + if sep_index == -1: + return content, None, "" + + sep = content[sep_index] + name = content[:sep_index].strip() + remainder = content[sep_index + 1 :].strip() + return name, sep, remainder + + +def _find_unquoted(text: str, targets: str) -> int: + in_single = False + in_double = False + in_backtick = False + idx = 0 + while idx < len(text): + if text.startswith("```", idx): + in_backtick = not in_backtick + idx += 3 + continue + char = text[idx] + if not in_double and char == "'": + if in_single and idx + 1 < len(text) and text[idx + 1] == "'": + idx += 2 + continue + in_single = not in_single + idx += 1 + continue + if not in_single and char == '"': + if in_double and idx + 1 < len(text) and text[idx + 1] == '"': + idx += 2 + continue + in_double = not in_double + idx += 1 + continue + if not in_single and not in_double and not in_backtick and char in targets: + return idx + idx += 1 + return -1 + + +def _split_meta(text: str) -> tuple[str, dict[str, Any] | None, str | None]: + if not text: + return "", None, None + + meta_index = _find_meta(text) + if meta_index == -1: + return text.strip(), None, None + + expr = text[:meta_index].strip() + meta_text = text[meta_index:].strip() + meta = _parse_meta(meta_text) + meta_raw = _extract_meta_raw(meta_text) + return expr, meta, meta_raw + + +def _find_meta(text: str) -> int: + in_single = False + in_double = False + in_backtick = False + idx = 0 + while idx < len(text): + if text.startswith("```", idx): + in_backtick = not in_backtick + idx += 3 + continue + char = text[idx] + if not in_double and char == "'": + if in_single and idx + 1 < len(text) and text[idx + 1] == "'": + idx += 2 + continue + in_single = not in_single + idx += 1 + continue + if not in_single and char == '"': + if in_double and idx + 1 < len(text) and text[idx + 1] == '"': + idx += 2 + continue + in_double = not in_double + idx += 1 + continue + if not in_single and not in_double and not in_backtick: + if text[idx:].lower().startswith("meta ["): + return idx + idx += 1 + return -1 + + +def _parse_meta(text: str) -> dict[str, Any]: + text = text.strip() + if not text.lower().startswith("meta"): + return {} + bracket_start = text.find("[") + bracket_end = text.rfind("]") + if bracket_start == -1 or bracket_end == -1 or bracket_end <= bracket_start: + return {} + content = text[bracket_start + 1 : bracket_end].strip() + if not content: + return {} + entries = _split_unquoted(content, ",") + meta: dict[str, Any] = {} + for entry in entries: + if "=" not in entry: + continue + key, value = entry.split("=", 1) + meta[key.strip()] = _parse_value(value.strip()) + return meta + + +def _extract_meta_raw(text: str) -> str | None: + bracket_start = text.find("[") + bracket_end = text.rfind("]") + if bracket_start == -1 or bracket_end == -1 or bracket_end <= bracket_start: + return None + return text[bracket_start + 1 : bracket_end] + + +def _split_unquoted(text: str, sep: str) -> list[str]: + parts: list[str] = [] + current: list[str] = [] + in_single = False + in_double = False + idx = 0 + while idx < len(text): + char = text[idx] + if not in_double and char == "'": + if in_single and idx + 1 < len(text) and text[idx + 1] == "'": + current.append("'") + idx += 2 + continue + in_single = not in_single + elif not in_single and char == '"': + if in_double and idx + 1 < len(text) and text[idx + 1] == '"': + current.append('"') + idx += 2 + continue + in_double = not in_double + elif not in_single and not in_double and char == sep: + parts.append("".join(current)) + current = [] + idx += 1 + continue + current.append(char) + idx += 1 + parts.append("".join(current)) + return [part.strip() for part in parts if part.strip()] + + +def _parse_value(raw: str) -> Any: + if raw == "": + return "" + lower = raw.lower() + if lower == "true": + return True + if lower == "false": + return False + if raw[0] in ("'", '"') and raw[-1] == raw[0]: + token, remainder, _raw = _parse_quoted(raw) + if remainder.strip() == "": + return token + return raw + + +def _strip_indent(raw: str, level: int, config: _IndentConfig | None) -> str: + if level <= 0: + return raw + if config is None: + return raw.lstrip(" \t") + if config.kind == "tabs": + prefix = "\t" * level + else: + prefix = " " * (config.width * level) + if raw.startswith(prefix): + return raw[len(prefix) :] + return raw.lstrip(" \t") + + +def _strip_common_indent(lines: list[str]) -> list[str]: + min_indent: int | None = None + for line in lines: + if not line.strip(): + continue + indent = len(line) - len(line.lstrip(" \t")) + if min_indent is None or indent < min_indent: + min_indent = indent + + if min_indent is None or min_indent <= 0: + return lines + + normalized: list[str] = [] + for line in lines: + if not line.strip(): + normalized.append("") + continue + normalized.append(line[min_indent:]) + return normalized + + +def merge_documents(documents: Iterable[TmdlDocument]) -> list[TmdlNode]: + def merge_into(scope: list[TmdlNode], incoming: TmdlNode) -> TmdlNode: + existing = next((node for node in scope if node.type == incoming.type and node.name == incoming.name), None) + if not existing: + scope.append(incoming) + return incoming + + existing.is_ref = existing.is_ref and incoming.is_ref + if not existing.name_raw and incoming.name_raw: + existing.name_raw = incoming.name_raw + if incoming.leading_comments and not existing.leading_comments: + existing.leading_comments = list(incoming.leading_comments) + if incoming.description: + if existing.description and existing.description != incoming.description: + raise TmdlParseError("Conflicting descriptions while merging", incoming.location) + existing.description = incoming.description + if incoming.default_property: + if existing.default_property and existing.default_property.text != incoming.default_property.text: + raise TmdlParseError("Conflicting default properties while merging", incoming.location) + existing.default_property = incoming.default_property + + for prop in incoming.properties: + existing_prop = existing.property(prop.name) + if existing_prop: + if existing_prop.value != prop.value: + raise TmdlParseError("Conflicting properties while merging", incoming.location) + continue + existing.properties.append(prop) + + for child in incoming.children: + merge_into(existing.children, child) + + return existing + + roots: list[TmdlNode] = [] + for doc in documents: + for node in doc.nodes: + merge_into(roots, node) + + return roots diff --git a/sidemantic/cli.py b/sidemantic/cli.py index f0a81948..3807f4bb 100644 --- a/sidemantic/cli.py +++ b/sidemantic/cli.py @@ -1,6 +1,7 @@ """CLI for sidemantic semantic layer operations.""" from pathlib import Path +from typing import Any import typer @@ -24,6 +25,86 @@ def version_callback(value: bool): _loaded_config: SidemanticConfig | None = None +def _parse_dax_query(text: str) -> Any: + from sidemantic.dax.runtime import parse_dax_query + + return parse_dax_query(text) + + +def _translate_dax_query_ast(query_ast: Any, layer: SemanticLayer) -> Any: + from sidemantic.dax.runtime import translate_dax_query_ast + + return translate_dax_query_ast(query_ast, layer.graph) + + +def _get_import_warnings(layer: SemanticLayer) -> list[dict[str, Any]]: + warnings = getattr(layer.graph, "import_warnings", None) + if not isinstance(warnings, list): + return [] + out: list[dict[str, Any]] = [] + for warning in warnings: + if isinstance(warning, dict): + out.append(warning) + return out + + +def _emit_import_warnings(layer: SemanticLayer, limit: int = 8) -> None: + warnings = _get_import_warnings(layer) + if not warnings: + return + typer.echo(f"Warning: {len(warnings)} model import warning(s).", err=True) + for warning in warnings[:limit]: + code = warning.get("code", "warning") + context = warning.get("context", "model") + name = warning.get("name", "") + message = warning.get("message", "") + file = warning.get("file") + line = warning.get("line") + column = warning.get("column") + location = "" + if file and line and column: + location = f" ({file}:{line}:{column})" + elif file: + location = f" ({file})" + typer.echo(f" - [{code}] {context} {name}: {message}{location}", err=True) + if len(warnings) > limit: + typer.echo(f" - ... {len(warnings) - limit} more warning(s)", err=True) + + +def _emit_dax_query_warnings(warnings: Any, limit: int = 8) -> None: + if not isinstance(warnings, list) or not warnings: + return + structured = [warning for warning in warnings if isinstance(warning, dict)] + if not structured: + return + typer.echo(f"Warning: {len(structured)} DAX query warning(s).", err=True) + for warning in structured[:limit]: + code = warning.get("code", "warning") + context = warning.get("context", "query") + message = warning.get("message", "") + detail = ", ".join( + f"{key}={value}" + for key in ("base_table", "table", "model", "name") + if (value := warning.get(key)) is not None + ) + suffix = f" ({detail})" if detail else "" + typer.echo(f" - [{code}] {context}: {message}{suffix}", err=True) + if len(structured) > limit: + typer.echo(f" - ... {len(structured) - limit} more warning(s)", err=True) + + +def _load_models_path(layer: SemanticLayer, models_path: Path) -> None: + """Load a directory of model files or a single supported model file into an existing layer.""" + if models_path.is_dir(): + load_from_directory(layer, str(models_path)) + return + if models_path.is_file(): + loaded_layer = SemanticLayer.from_yaml(models_path, connection=layer.adapter) + layer.graph = loaded_layer.graph + return + raise ValueError(f"Model path {models_path} does not exist") + + @app.callback() def main( version: bool = typer.Option( @@ -383,7 +464,7 @@ def query( sidemantic query "SELECT revenue FROM orders" --dry-run """ if not models.exists(): - typer.echo(f"Error: Directory {models} does not exist", err=True) + typer.echo(f"Error: Model path {models} does not exist", err=True) raise typer.Exit(1) try: @@ -417,7 +498,7 @@ def query( ) else: layer = SemanticLayer(preagg_database=preagg_db, preagg_schema=preagg_sch) - load_from_directory(layer, str(models)) + _load_models_path(layer, models) if not layer.graph.models: typer.echo("Error: No models found", err=True) @@ -459,6 +540,99 @@ def query( raise typer.Exit(1) +@app.command("dax-query") +def dax_query( + dax: str = typer.Argument(..., help="DAX query to execute"), + models: Path = typer.Option(".", "--models", "-m", help="Directory containing semantic layer files"), + output: Path = typer.Option(None, "--output", "-o", help="Output file (default: stdout)"), + connection: str = typer.Option( + None, "--connection", help="Database connection string (e.g., postgres://host/db, bigquery://project/dataset)" + ), + db: Path = typer.Option(None, "--db", help="Path to DuckDB database file (shorthand for duckdb:/// connection)"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show translated SQL without executing"), + evaluate: int = typer.Option(1, "--evaluate", help="1-based EVALUATE statement index to execute"), +): + """ + Execute a DAX query and output results as CSV. + + Examples: + sidemantic dax-query "EVALUATE VALUES('orders'[status])" + sidemantic dax-query "EVALUATE VALUES('orders'[status])" --db data.duckdb + sidemantic dax-query "EVALUATE VALUES('orders'[status])" --dry-run + sidemantic dax-query "EVALUATE ...; EVALUATE ..." --evaluate 2 + """ + if evaluate < 1: + typer.echo("Error: --evaluate must be >= 1", err=True) + raise typer.Exit(1) + + if not models.exists(): + typer.echo(f"Error: Model path {models} does not exist", err=True) + raise typer.Exit(1) + + try: + connection_str = None + init_sql = None + if connection: + connection_str = connection + elif db: + connection_str = f"duckdb:///{db.absolute()}" + elif _loaded_config and _loaded_config.connection: + connection_str = build_connection_string(_loaded_config) + init_sql = get_init_sql(_loaded_config) + else: + data_dir = models / "data" + if data_dir.exists(): + db_files = list(data_dir.glob("*.db")) + if db_files: + connection_str = f"duckdb:///{db_files[0].absolute()}" + + preagg_db = _loaded_config.preagg_database if _loaded_config else None + preagg_sch = _loaded_config.preagg_schema if _loaded_config else None + if connection_str: + layer = SemanticLayer( + connection=connection_str, + preagg_database=preagg_db, + preagg_schema=preagg_sch, + init_sql=init_sql, + ) + else: + layer = SemanticLayer(preagg_database=preagg_db, preagg_schema=preagg_sch) + _load_models_path(layer, models) + _emit_import_warnings(layer) + + if not layer.graph.models: + typer.echo("Error: No models found", err=True) + raise typer.Exit(1) + + dax_payload = layer.compile_dax_query_payload(dax, evaluate=evaluate) + translated_sql = str(dax_payload["sql"]) + _emit_dax_query_warnings(dax_payload.get("warnings")) + if dry_run: + typer.echo(translated_sql) + return + + result = layer.adapter.execute(translated_sql) + columns = [desc[0] for desc in result.description] + rows = result.fetchall() + + import csv + import sys + + if output: + with open(output, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(columns) + writer.writerows(rows) + typer.echo(f"Results written to {output}", err=True) + else: + writer = csv.writer(sys.stdout) + writer.writerow(columns) + writer.writerows(rows) + except Exception as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + + @app.command() def serve( directory: Path = typer.Argument(".", help="Directory containing semantic layer files (defaults to current dir)"), diff --git a/sidemantic/core/dimension.py b/sidemantic/core/dimension.py index 6b39c2b1..b3df5ccc 100644 --- a/sidemantic/core/dimension.py +++ b/sidemantic/core/dimension.py @@ -14,6 +14,10 @@ class Dimension(BaseModel): name: str = Field(..., description="Unique dimension name within model") type: Literal["categorical", "time", "boolean", "numeric"] = Field(..., description="Dimension type") sql: str | None = Field(None, description="SQL expression (defaults to name; accepts 'expr' as alias)") + dax: str | None = Field(None, description="DAX expression source text to lower into SQL") + expression_language: Literal["sql", "dax"] | None = Field( + None, description="Expression language for sql/expr/dax authoring" + ) granularity: Literal["second", "minute", "hour", "day", "week", "month", "quarter", "year"] | None = Field( None, description="Base granularity for time dimensions" ) diff --git a/sidemantic/core/introspection.py b/sidemantic/core/introspection.py new file mode 100644 index 00000000..37af24da --- /dev/null +++ b/sidemantic/core/introspection.py @@ -0,0 +1,346 @@ +"""Structured semantic graph metadata for UI/FFI consumers.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from typing import Any + +from sidemantic.core.metric import Metric +from sidemantic.core.model import Model +from sidemantic.core.relationship import Relationship +from sidemantic.core.semantic_graph import SemanticGraph + + +def describe_graph(graph: SemanticGraph, model_names: list[str] | None = None) -> dict[str, Any]: + warnings = _warnings(graph) + requested = set(model_names or []) + models = [ + _describe_model(model, warnings) for model in graph.models.values() if not requested or model.name in requested + ] + metrics = [ + _describe_metric(metric, warnings, model_name=None) + for metric in graph.metrics.values() + if _include_graph_metric(metric, requested) + ] + + return { + "models": models, + "metrics": metrics, + "import_warnings": warnings, + } + + +def _include_graph_metric(metric: Metric, requested_models: set[str]) -> bool: + if not requested_models: + return True + owner_model = _metric_owner_model(metric) + if owner_model and owner_model in requested_models: + return True + required_models = getattr(metric, "required_models", None) or [] + if not required_models: + return True + return set(required_models).issubset(requested_models) + + +def _metric_owner_model(metric: Metric) -> str | None: + base_metric = getattr(metric, "base_metric", None) + if isinstance(base_metric, str) and "." in base_metric: + model_name, _metric_name = base_metric.split(".", 1) + return model_name + return None + + +def _describe_model(model: Model, warnings: list[dict[str, Any]]) -> dict[str, Any]: + model_kind = _model_kind(model) + info: dict[str, Any] = { + "name": model.name, + "kind": model_kind, + "table": model.table, + "sql": model.sql, + "primary_key": model.primary_key, + "dimensions": [_describe_dimension(dimension, warnings, model) for dimension in model.dimensions], + "metrics": [_describe_metric(metric, warnings, model.name, model=model) for metric in model.metrics], + "relationships": [ + _describe_relationship(relationship, warnings, model=model) for relationship in model.relationships + ], + "segments": [segment.name for segment in model.segments], + } + if model_kind == "calculated_table": + info["calculated_table"] = True + _add_common_fields(info, model, warnings, context="calculated_table") + if model.description: + info["description"] = model.description + if model.default_time_dimension: + info["default_time_dimension"] = model.default_time_dimension + if model.default_grain: + info["default_grain"] = model.default_grain + if model.meta: + info["meta"] = model.meta + return _drop_none(info) + + +def _model_kind(model: Model) -> str: + tmdl_node_type = str(getattr(model, "_tmdl_node_type", "")).lower() + if tmdl_node_type == "calculatedtable": + return "calculated_table" + if getattr(model, "expression_language", None) == "dax" or _dax_expression_text(model): + return "calculated_table" + if model.sql and not model.table: + return "derived_table" + return "table" + + +def _describe_dimension(dimension: Any, warnings: list[dict[str, Any]], model: Model) -> dict[str, Any]: + info: dict[str, Any] = { + "name": dimension.name, + "type": dimension.type, + "sql": dimension.sql, + "expression_language": getattr(dimension, "expression_language", None), + "granularity": dimension.granularity, + "supported_granularities": dimension.supported_granularities, + "public": dimension.public, + } + _add_common_fields(info, dimension, warnings, context="column", model_name=model.name, inherited_from=model) + if dimension.description: + info["description"] = dimension.description + if dimension.label: + info["label"] = dimension.label + if dimension.format: + info["format"] = dimension.format + if dimension.meta: + info["meta"] = dimension.meta + return _drop_none(info) + + +def _describe_metric( + metric: Metric, warnings: list[dict[str, Any]], model_name: str | None, model: Model | None = None +) -> dict[str, Any]: + info: dict[str, Any] = { + "name": metric.name, + "agg": metric.agg, + "sql": metric.sql, + "type": metric.type, + "expression_language": getattr(metric, "expression_language", None), + "base_metric": metric.base_metric, + "comparison_type": metric.comparison_type, + "calculation": metric.calculation, + "time_offset": metric.time_offset, + "window": metric.window, + "grain_to_date": metric.grain_to_date, + "window_order": metric.window_order, + "filters": metric.filters or [], + "drill_fields": metric.drill_fields or [], + "required_models": metric.required_models, + "relationship_overrides": [ + _relationship_override_info(override) for override in metric.relationship_overrides or [] + ], + "public": metric.public, + } + _add_common_fields(info, metric, warnings, context="measure", model_name=model_name, inherited_from=model) + if metric.description: + info["description"] = metric.description + if metric.label: + info["label"] = metric.label + if metric.format: + info["format"] = metric.format + if metric.meta: + info["meta"] = metric.meta + result = _drop_none(info) + # Current Sidequery Swift DTOs decode these as non-optional arrays. Keep + # them present even when empty while Sidequery moves to richer metadata. + result.setdefault("filters", []) + result.setdefault("drill_fields", []) + return result + + +def _describe_relationship( + relationship: Relationship, warnings: list[dict[str, Any]], model: Model | None = None +) -> dict[str, Any]: + tmdl_name = getattr(relationship, "_tmdl_relationship_name", None) + info: dict[str, Any] = { + "name": relationship.name, + "type": relationship.type, + "foreign_key": relationship.foreign_key, + "primary_key": relationship.primary_key, + "through": relationship.through, + "through_foreign_key": relationship.through_foreign_key, + "related_foreign_key": relationship.related_foreign_key, + "metadata": relationship.metadata, + } + if relationship.active is not True: + info["active"] = relationship.active + _add_common_fields( + info, + relationship, + warnings, + context="relationship", + model_name=model.name if model else None, + inherited_from=model, + alternate_warning_names=[tmdl_name] if tmdl_name else None, + ) + if tmdl_name: + info["tmdl_name"] = tmdl_name + return _drop_none(info) + + +def _add_common_fields( + info: dict[str, Any], + obj: Any, + warnings: list[dict[str, Any]], + *, + context: str, + model_name: str | None = None, + inherited_from: Any | None = None, + alternate_warning_names: list[str] | None = None, +) -> None: + source_format = getattr(obj, "_source_format", None) or getattr(inherited_from, "_source_format", None) + if source_format: + info["source_format"] = source_format + source_file = getattr(obj, "_source_file", None) or getattr(inherited_from, "_source_file", None) + if source_file: + info["source_file"] = source_file + + tmdl_expression = getattr(obj, "_tmdl_expression", None) + dax_expression = _dax_expression_text(obj) + if tmdl_expression: + info["tmdl_expression"] = tmdl_expression + if dax_expression: + info["dax"] = dax_expression + if tmdl_expression or dax_expression: + info["original_expression"] = tmdl_expression or dax_expression + + lowered = bool(getattr(obj, "_dax_lowered", False)) + if lowered: + info["dax_lowered"] = True + if required_models := getattr(obj, "_dax_required_models", None): + info["dax_required_models"] = required_models + + tmdl_metadata = _tmdl_metadata(obj) + if tmdl_metadata: + info["tmdl"] = tmdl_metadata + + _add_warning_fields( + info, + getattr(obj, "name", None), + context, + warnings, + model_name=model_name, + alternate_names=alternate_warning_names, + ) + if "import_warnings" not in info and (tmdl_expression or dax_expression): + info["faithful_lowering"] = True + + +def _tmdl_metadata(obj: Any) -> dict[str, Any]: + fields = { + "node_type": "_tmdl_node_type", + "name_raw": "_tmdl_name_raw", + "relationship_name": "_tmdl_relationship_name", + "relationship_name_raw": "_tmdl_relationship_name_raw", + "data_type": "_tmdl_data_type", + "description": "_tmdl_description", + "properties": "_tmdl_properties", + "relationship_properties": "_tmdl_relationship_properties", + "raw_value_properties": "_tmdl_raw_value_properties", + "property_order": "_tmdl_property_order", + "leading_comments": "_tmdl_leading_comments", + "child_nodes": "_tmdl_child_nodes", + } + metadata: dict[str, Any] = {} + for output_key, attr in fields.items(): + value = getattr(obj, attr, None) + if value is None or value == [] or value == {}: + continue + metadata[output_key] = _json_safe(value) + + if getattr(obj, "_tmdl_is_active_explicit", False): + metadata["is_active_explicit"] = True + + return metadata + + +def _add_warning_fields( + info: dict[str, Any], + name: str | None, + context: str, + warnings: list[dict[str, Any]], + *, + model_name: str | None = None, + alternate_names: list[str] | None = None, +) -> None: + matched = [ + warning + for warning in warnings + if warning.get("context") == context and _warning_matches(warning, name, model_name, alternate_names) + ] + if not matched: + return + info["import_warnings"] = matched + info["unsupported"] = any( + warning.get("code") + in {"dax_parse_error", "dax_parser_unavailable", "dax_translation_fallback", "relationship_parse_skip"} + for warning in matched + ) + if context in {"column", "measure", "calculated_table", "relationship"}: + info["faithful_lowering"] = not info["unsupported"] + + +def _dax_expression_text(obj: Any) -> str | None: + for attr in ("_dax_expression", "dax"): + value = getattr(obj, attr, None) + if isinstance(value, str) and value.strip(): + return value + return None + + +def _warning_matches( + warning: dict[str, Any], + name: str | None, + model_name: str | None, + alternate_names: list[str] | None = None, +) -> bool: + warning_model = warning.get("model") + if model_name and warning_model and warning_model != model_name: + return False + + if name is None: + return True + + warning_name = warning.get("name") + if warning_name == name: + return True + for alternate_name in alternate_names or []: + if alternate_name and warning_name == alternate_name: + return True + if model_name and warning_name == f"{model_name}.{name}": + return True + if warning_model and warning_name == f"{warning_model}.{name}": + return True + return False + + +def _warnings(graph: SemanticGraph) -> list[dict[str, Any]]: + warnings = getattr(graph, "import_warnings", []) or [] + return [dict(warning) for warning in warnings if isinstance(warning, dict)] + + +def _json_safe(value: Any) -> Any: + if is_dataclass(value): + return _json_safe(asdict(value)) + if hasattr(value, "model_dump"): + return _json_safe(value.model_dump(exclude_none=True)) + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items() if item is not None} + if isinstance(value, (list, tuple, set)): + return [_json_safe(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _drop_none(value: dict[str, Any]) -> dict[str, Any]: + return {key: item for key, item in value.items() if item is not None and item != []} + + +def _relationship_override_info(override: Any) -> dict[str, Any]: + return _json_safe(override) diff --git a/sidemantic/core/metric.py b/sidemantic/core/metric.py index 78f0d93c..a69d1094 100644 --- a/sidemantic/core/metric.py +++ b/sidemantic/core/metric.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, model_validator from .dependency_analyzer import extract_metric_dependencies +from .relationship import RelationshipOverride class Metric(BaseModel): @@ -50,6 +51,14 @@ def __init__(self, **data): | None ) = Field(None, description="Aggregation function (for simple measures)") sql: str | None = Field(None, description="SQL expression or formula (accepts 'expr' as alias)") + dax: str | None = Field(None, description="DAX expression source text to lower into SQL") + expression_language: Literal["sql", "dax"] | None = Field( + None, description="Expression language for sql/expr/dax authoring" + ) + relationship_overrides: list[RelationshipOverride] | None = Field( + None, description="Relationship overrides for this metric" + ) + required_models: list[str] | None = Field(None, description="Additional models required for this metric") @model_validator(mode="before") @classmethod @@ -83,10 +92,11 @@ def handle_expr_and_parse_agg(cls, data): # Step 2: Parse aggregation from SQL if needed agg_val = data.get("agg") type_val = data.get("type") + language_val = data.get("expression_language") # Parse if sql is provided and agg is not set # Allow parsing for simple metrics (no type) OR cumulative metrics (to support AVG/COUNT windows) - if sql_val and not agg_val and (not type_val or type_val == "cumulative"): + if sql_val and language_val != "dax" and not agg_val and (not type_val or type_val == "cumulative"): try: import sqlglot from sqlglot import expressions as exp @@ -271,7 +281,7 @@ def validate_type_specific_fields(self): None, description="Type of time comparison" ) time_offset: str | None = Field(None, description="Custom time offset (e.g., '1 month')") - calculation: Literal["difference", "percent_change", "ratio"] | None = Field( + calculation: Literal["difference", "percent_change", "ratio", "previous_value"] | None = Field( None, description="Comparison calculation (default: percent_change)" ) diff --git a/sidemantic/core/model.py b/sidemantic/core/model.py index 1286cb47..bcec9984 100644 --- a/sidemantic/core/model.py +++ b/sidemantic/core/model.py @@ -21,6 +21,10 @@ class Model(BaseModel): name: str = Field(..., description="Unique model name") table: str | None = Field(None, description="Physical table name (schema.table)") sql: str | None = Field(None, description="SQL expression for derived tables") + dax: str | None = Field(None, description="DAX table expression source text to lower into SQL") + expression_language: Literal["sql", "dax"] | None = Field( + None, description="Expression language for sql/dax derived table authoring" + ) source_uri: str | None = Field(None, description="Remote data source URI (e.g., https://, s3://, gs://)") description: str | None = Field(None, description="Human-readable description") extends: str | None = Field(None, description="Parent model to inherit from") diff --git a/sidemantic/core/relationship.py b/sidemantic/core/relationship.py index 8d350031..828c96ec 100644 --- a/sidemantic/core/relationship.py +++ b/sidemantic/core/relationship.py @@ -32,6 +32,7 @@ class Relationship(BaseModel): related_foreign_key: str | None = Field( default=None, description="Foreign key in junction model pointing to related model" ) + active: bool = Field(default=True, description="Whether the relationship is active by default") metadata: dict[str, Any] | None = Field(None, description="Adapter-specific metadata payload") @property @@ -83,3 +84,16 @@ def junction_keys(self) -> tuple[str | None, str | None]: if self.type != "many_to_many": return None, None return self.through_foreign_key or self.foreign_key, self.related_foreign_key + + +class RelationshipOverride(BaseModel): + """Overrides join behavior for a relationship in a metric/query context.""" + + from_model: str = Field(description="Source model name") + from_column: str = Field(description="Source model column") + to_model: str = Field(description="Target model name") + to_column: str = Field(description="Target model column") + join_type: Literal["inner", "left", "right", "full"] | None = Field( + default=None, description="Optional join type override" + ) + direction: str | None = Field(default=None, description="Optional filter direction hint") diff --git a/sidemantic/core/semantic_graph.py b/sidemantic/core/semantic_graph.py index 6e91f336..c09b6edf 100644 --- a/sidemantic/core/semantic_graph.py +++ b/sidemantic/core/semantic_graph.py @@ -6,9 +6,17 @@ from sidemantic.core.metric import Metric from sidemantic.core.model import Model from sidemantic.core.parameter import Parameter +from sidemantic.core.relationship import RelationshipOverride from sidemantic.core.table_calculation import TableCalculation +def _relationship_local_key_columns(model: Model, relationship: object) -> list[str]: + tmdl_from_column = getattr(relationship, "_tmdl_from_column", None) + if isinstance(tmdl_from_column, str) and tmdl_from_column.strip(): + return [tmdl_from_column] + return model.primary_key_columns + + @dataclass class JoinPath: """Represents a join between two models.""" @@ -18,6 +26,7 @@ class JoinPath: from_columns: list[str] # Foreign key column(s) in from_model to_columns: list[str] # Primary/unique key column(s) in to_model relationship: str # many_to_one, one_to_many, one_to_one + join_type_override: str | None = None # Backwards compatibility properties (return first column) @property @@ -43,9 +52,17 @@ def __init__(self): self.metrics: dict[str, Metric] = {} self.table_calculations: dict[str, TableCalculation] = {} self.parameters: dict[str, Parameter] = {} + self.import_warnings: list[dict[str, object]] = [] + self._revision = 0 self._adjacency: dict[ - str, list[tuple[str, list[str], list[str], str]] - ] = {} # model -> [(to_model, from_keys, to_keys, rel_type)] + str, list[tuple[str, list[str], list[str], str, str | None]] + ] = {} # model -> [(to_model, from_keys, to_keys, rel_type, join_type_override)] + self._relationship_path_cache: dict[tuple[int, str, str], tuple[JoinPath, ...]] = {} + + def _mark_structure_dirty(self) -> None: + self._revision += 1 + self._adjacency_dirty = True + self._relationship_path_cache.clear() def add_model(self, model: Model) -> None: """Add a model to the graph. @@ -68,7 +85,7 @@ def add_model(self, model: Model) -> None: if metric.name not in self.metrics: self.metrics[metric.name] = metric - self._adjacency_dirty = True + self._mark_structure_dirty() def add_metric(self, measure: Metric) -> None: """Add a measure to the graph. @@ -80,6 +97,7 @@ def add_metric(self, measure: Metric) -> None: raise ValueError(f"Measure {measure.name} already exists") self.metrics[measure.name] = measure + self._revision += 1 def add_table_calculation(self, calc: TableCalculation) -> None: """Add a table calculation to the graph. @@ -91,6 +109,7 @@ def add_table_calculation(self, calc: TableCalculation) -> None: raise ValueError(f"Table calculation {calc.name} already exists") self.table_calculations[calc.name] = calc + self._revision += 1 def get_table_calculation(self, name: str) -> TableCalculation: """Get a table calculation by name. @@ -122,6 +141,7 @@ def add_parameter(self, param: Parameter) -> None: raise ValueError(f"Parameter {param.name} already exists") self.parameters[param.name] = param + self._revision += 1 def get_parameter(self, name: str) -> Parameter: """Get a parameter by name. @@ -194,13 +214,19 @@ def build_adjacency(self) -> None: if not hasattr(self, "_adjacency"): self._adjacency = {} self._adjacency.clear() + self._relationship_path_cache.clear() def add_edge( - from_model: str, to_model: str, from_keys: list[str], to_keys: list[str], relationship_type: str + from_model: str, + to_model: str, + from_keys: list[str], + to_keys: list[str], + relationship_type: str, + join_type_override: str | None = None, ) -> None: if from_model not in self._adjacency: self._adjacency[from_model] = [] - self._adjacency[from_model].append((to_model, from_keys, to_keys, relationship_type)) + self._adjacency[from_model].append((to_model, from_keys, to_keys, relationship_type, join_type_override)) def invert_relationship(relationship_type: str) -> str: if relationship_type == "many_to_one": @@ -212,6 +238,8 @@ def invert_relationship(relationship_type: str) -> str: # Build adjacency from join relationships for model_name, model in self.models.items(): for relationship in model.relationships: + if not relationship.active: + continue related_model = relationship.name if related_model not in self.models: continue # Skip if related model doesn't exist yet @@ -258,18 +286,26 @@ def invert_relationship(relationship_type: str) -> str: else: # one_to_one or one_to_many: related model has foreign key pointing here # Example: customers one_to_many orders (customers.id <- orders.customer_id) - local_keys = model.primary_key_columns # Use model's primary key + local_keys = _relationship_local_key_columns(model, relationship) remote_keys = relationship.foreign_key_columns # [customer_id] (in orders) add_edge(model_name, related_model, local_keys, remote_keys, relationship.type) add_edge(related_model, model_name, remote_keys, local_keys, invert_relationship(relationship.type)) - def find_relationship_path(self, from_model: str, to_model: str) -> list[JoinPath]: + def find_relationship_path( + self, + from_model: str, + to_model: str, + relationship_overrides: list[RelationshipOverride] | None = None, + ) -> list[JoinPath]: """Find join path between two models using BFS. Args: from_model: Source model name to_model: Target model name + relationship_overrides: Query-local relationship edges that take + precedence over active graph relationships between the same + model pair. Returns: List of JoinPath objects representing the join sequence @@ -289,6 +325,16 @@ def find_relationship_path(self, from_model: str, to_model: str) -> list[JoinPat if to_model not in self.models: raise KeyError(f"Model {to_model} not found") + adjacency = self._adjacency + if relationship_overrides: + adjacency = self._adjacency_with_relationship_overrides(relationship_overrides) + + cache_key = (self._revision, from_model, to_model) + if not relationship_overrides: + cached = self._relationship_path_cache.get(cache_key) + if cached is not None: + return list(cached) + # BFS to find shortest path queue = deque([(from_model, [])]) visited = {from_model} @@ -296,10 +342,10 @@ def find_relationship_path(self, from_model: str, to_model: str) -> list[JoinPat while queue: current, path = queue.popleft() - if current not in self._adjacency: + if current not in adjacency: continue - for next_model, from_keys, to_keys, relationship_type in self._adjacency[current]: + for next_model, from_keys, to_keys, relationship_type, join_type_override in adjacency[current]: if next_model in visited: continue @@ -312,16 +358,56 @@ def find_relationship_path(self, from_model: str, to_model: str) -> list[JoinPat from_columns=from_keys, to_columns=to_keys, relationship=relationship_type, + join_type_override=join_type_override, ) ] if next_model == to_model: + if not relationship_overrides: + self._relationship_path_cache[cache_key] = tuple(new_path) return new_path queue.append((next_model, new_path)) raise ValueError(f"No join path found between {from_model} and {to_model}") + def _adjacency_with_relationship_overrides( + self, relationship_overrides: list[RelationshipOverride] + ) -> dict[str, list[tuple[str, list[str], list[str], str, str | None]]]: + adjacency = {model: list(edges) for model, edges in self._adjacency.items()} + + for override in relationship_overrides: + if override.from_model not in self.models or override.to_model not in self.models: + continue + + from_model = override.from_model + to_model = override.to_model + pair = frozenset((from_model, to_model)) + + for model_name, edges in list(adjacency.items()): + adjacency[model_name] = [edge for edge in edges if frozenset((model_name, edge[0])) != pair] + + adjacency.setdefault(from_model, []).append( + ( + to_model, + [override.from_column], + [override.to_column], + "many_to_one", + override.join_type, + ) + ) + adjacency.setdefault(to_model, []).append( + ( + from_model, + [override.to_column], + [override.from_column], + "one_to_many", + override.join_type, + ) + ) + + return adjacency + def find_all_models_for_query(self, dimensions: list[str], measures: list[str]) -> set[str]: """Find all models needed for a query. diff --git a/sidemantic/core/semantic_layer.py b/sidemantic/core/semantic_layer.py index c41d51b3..af8aad99 100644 --- a/sidemantic/core/semantic_layer.py +++ b/sidemantic/core/semantic_layer.py @@ -209,6 +209,8 @@ def add_model(self, model: Model) -> None: if model.auto_dimensions: self._introspect_dimensions(model) + self._lower_model_dax(model) + errors = validate_model(model) if errors: raise ModelValidationError( @@ -217,6 +219,16 @@ def add_model(self, model: Model) -> None: self.graph.add_model(model) + def _lower_model_dax(self, model: Model) -> None: + from sidemantic.core.semantic_graph import SemanticGraph + from sidemantic.dax.modeling import lower_dax_model_expressions + + graph = SemanticGraph() + graph.models.update(self.graph.models) + graph.metrics.update(self.graph.metrics) + graph.models[model.name] = model + lower_dax_model_expressions(model, graph) + def _normalize_model_table(self, model: Model) -> None: """Normalize model.table for the active dialect when needed.""" if not model.table or model.sql: @@ -425,6 +437,8 @@ def add_metric(self, measure: Metric) -> None: if existing.model_dump() == measure.model_dump(): return + self._lower_graph_metric_dax(measure) + errors = validate_metric(measure, self.graph) if errors: raise MetricValidationError( @@ -433,6 +447,16 @@ def add_metric(self, measure: Metric) -> None: self.graph.add_metric(measure) + def _lower_graph_metric_dax(self, measure: Metric) -> None: + from sidemantic.core.semantic_graph import SemanticGraph + from sidemantic.dax.modeling import lower_dax_graph_expressions + + graph = SemanticGraph() + graph.models.update(self.graph.models) + graph.metrics.update(self.graph.metrics) + graph.metrics[measure.name] = measure + lower_dax_graph_expressions(graph) + def query( self, metrics: list[str] | None = None, @@ -480,6 +504,113 @@ def query( return self.adapter.execute(sql) + def translate_dax_query(self, dax: str): + """Parse and translate a DAX query against this semantic graph. + + Returns a ``QueryTranslation`` with one SQL payload per DAX EVALUATE + statement. This is the reusable API behind CLI/MCP/Sidequery DAX query + execution. + """ + from sidemantic.dax.runtime import parse_dax_query, translate_dax_query_ast + + return translate_dax_query_ast(parse_dax_query(dax), self.graph) + + def compile_dax_query(self, dax: str, evaluate: int = 1) -> str: + """Compile one DAX EVALUATE statement to SQL without executing it.""" + sql, _warnings = self._compile_dax_query_with_warnings(dax, evaluate=evaluate) + return sql + + def compile_dax_query_payload(self, dax: str, evaluate: int = 1) -> dict[str, object]: + """Compile one DAX EVALUATE statement to JSON-friendly SQL metadata.""" + sql, warnings = self._compile_dax_query_with_warnings(dax, evaluate=evaluate) + return { + "sql": sql, + "warnings": warnings, + "import_warnings": self.get_import_warnings(), + } + + def _compile_dax_query_with_warnings(self, dax: str, evaluate: int = 1) -> tuple[str, list[dict[str, object]]]: + """Compile one DAX EVALUATE and collect query-level warnings.""" + if evaluate < 1: + raise ValueError("evaluate must be >= 1") + + translation = self.translate_dax_query(dax) + if not translation.evaluates: + raise ValueError("DAX query contains no EVALUATE statements") + if evaluate > len(translation.evaluates): + raise ValueError( + f"evaluate index {evaluate} is out of range; query has {len(translation.evaluates)} EVALUATE statement(s)" + ) + evaluate_translation = translation.evaluates[evaluate - 1] + warnings = [ + *self._normalize_dax_warnings(getattr(translation, "warnings", [])), + *self._normalize_dax_warnings(getattr(evaluate_translation, "warnings", [])), + ] + return evaluate_translation.sql, warnings + + def query_dax(self, dax: str, evaluate: int = 1): + """Execute one DAX EVALUATE statement against the semantic layer.""" + return self.adapter.execute(self.compile_dax_query(dax, evaluate=evaluate)) + + def run_dax_query(self, dax: str, evaluate: int = 1, dry_run: bool = False) -> dict[str, object]: + """Compile and optionally execute one DAX EVALUATE statement. + + Returns a JSON-friendly payload for UI, MCP, and FFI callers. + """ + sql, warnings = self._compile_dax_query_with_warnings(dax, evaluate=evaluate) + import_warnings = self.get_import_warnings() + if dry_run: + return { + "sql": sql, + "rows": [], + "row_count": 0, + "warnings": warnings, + "import_warnings": import_warnings, + } + + result = self.adapter.execute(sql) + rows = result.fetchall() + columns = [desc[0] for desc in result.description] + row_dicts = [ + {column: self._result_value_to_json_compatible(value) for column, value in zip(columns, row)} + for row in rows + ] + + return { + "sql": sql, + "rows": row_dicts, + "row_count": len(row_dicts), + "warnings": warnings, + "import_warnings": import_warnings, + } + + @staticmethod + def _normalize_dax_warnings(warnings: object) -> list[dict[str, object]]: + if not isinstance(warnings, list): + return [] + return [dict(warning) for warning in warnings if isinstance(warning, dict)] + + @staticmethod + def _result_value_to_json_compatible(value): + from datetime import date, datetime, time + from decimal import Decimal + + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (date, datetime, time)): + return value.isoformat() + return value + + def get_import_warnings(self) -> list[dict[str, object]]: + """Return structured warnings produced while importing model definitions.""" + return list(getattr(self.graph, "import_warnings", []) or []) + + def describe_models(self, model_names: list[str] | None = None) -> dict[str, object]: + """Return UI/FFI-friendly model metadata, including source and DAX/TMDL state.""" + from sidemantic.core.introspection import describe_graph + + return describe_graph(self.graph, model_names=model_names) + def compile( self, metrics: list[str] | None = None, @@ -816,10 +947,10 @@ def from_yaml( path: str | Path, connection: str | BaseDatabaseAdapter | None = None, # type: ignore # noqa: F821 ) -> SemanticLayer: - """Load semantic layer from native YAML file. + """Load semantic layer from a native YAML or standalone TMDL file. Args: - path: Path to YAML file + path: Path to YAML, SQL, or standalone TMDL file connection: Database connection string, adapter instance, or None (overrides connection in YAML file). Pass an adapter instance when your model files don't include connection config, e.g.: @@ -828,24 +959,30 @@ def from_yaml( Returns: SemanticLayer instance """ - import yaml + path_obj = Path(path) + if path_obj.suffix.lower() == ".tmdl": + from sidemantic.adapters.tmdl import TMDLAdapter - from sidemantic.adapters.sidemantic import SidemanticAdapter, substitute_env_vars + graph = TMDLAdapter().parse(path) + else: + import yaml - adapter = SidemanticAdapter() - graph = adapter.parse(path) + from sidemantic.adapters.sidemantic import SidemanticAdapter, substitute_env_vars - # If connection not provided as parameter, try to read from YAML file - # (skip for .sql files which may have multi-document YAML frontmatter) - path_obj = Path(path) - if connection is None and path_obj.suffix in (".yml", ".yaml"): - with open(path) as f: - content = f.read() - # Substitute environment variables - content = substitute_env_vars(content) - data = yaml.safe_load(content) - if data and "connection" in data: - connection = data["connection"] + adapter = SidemanticAdapter() + graph = adapter.parse(path) + cls._mark_loaded_file_source(graph, source_format="Sidemantic", source_file=path_obj.name) + + # If connection not provided as parameter, try to read from YAML file + # (skip for .sql files which may have multi-document YAML frontmatter) + if connection is None and path_obj.suffix in (".yml", ".yaml"): + with open(path) as f: + content = f.read() + # Substitute environment variables + content = substitute_env_vars(content) + data = yaml.safe_load(content) + if data and "connection" in data: + connection = data["connection"] # Convert dict-style connection config to URL string if isinstance(connection, dict): @@ -860,6 +997,19 @@ def from_yaml( return layer + @staticmethod + def _mark_loaded_file_source(graph, *, source_format: str, source_file: str) -> None: + for model in graph.models.values(): + if not hasattr(model, "_source_format"): + model._source_format = source_format + if not hasattr(model, "_source_file"): + model._source_file = source_file + for metric in graph.metrics.values(): + if not hasattr(metric, "_source_format"): + metric._source_format = source_format + if not hasattr(metric, "_source_file"): + metric._source_file = source_file + @staticmethod def _connection_dict_to_url(config: dict) -> str: """Convert dict-style connection config to URL string. diff --git a/sidemantic/dax/__init__.py b/sidemantic/dax/__init__.py new file mode 100644 index 00000000..21f763c5 --- /dev/null +++ b/sidemantic/dax/__init__.py @@ -0,0 +1,33 @@ +"""DAX translation helpers.""" + +from sidemantic.dax.modeling import DaxModelingError, lower_dax_graph_expressions, lower_dax_model_expressions +from sidemantic.dax.translator import ( + DaxTranslationError, + MetricTranslation, + QueryEvaluateTranslation, + QueryTranslation, + RelationshipEdge, + RelationshipOverride, + TableTranslation, + translate_dax_metric, + translate_dax_query, + translate_dax_scalar, + translate_dax_table, +) + +__all__ = [ + "DaxTranslationError", + "DaxModelingError", + "MetricTranslation", + "QueryEvaluateTranslation", + "QueryTranslation", + "RelationshipEdge", + "RelationshipOverride", + "TableTranslation", + "translate_dax_query", + "translate_dax_metric", + "translate_dax_scalar", + "translate_dax_table", + "lower_dax_graph_expressions", + "lower_dax_model_expressions", +] diff --git a/sidemantic/dax/modeling.py b/sidemantic/dax/modeling.py new file mode 100644 index 00000000..1bb0b753 --- /dev/null +++ b/sidemantic/dax/modeling.py @@ -0,0 +1,335 @@ +"""Lower DAX-authored model definitions into executable Sidemantic fields.""" + +from __future__ import annotations + +from typing import Any + +from sidemantic.core.metric import Metric +from sidemantic.core.model import Model +from sidemantic.core.semantic_graph import SemanticGraph +from sidemantic.dax.translator import ( + DaxTranslationError, + RelationshipEdge, + translate_dax_metric, + translate_dax_scalar, + translate_dax_table, +) + + +class DaxModelingError(ValueError): + """Raised when a DAX-authored model definition cannot be lowered.""" + + +def lower_dax_graph_expressions(graph: SemanticGraph) -> None: + """Lower first-class DAX expressions on graph models and graph metrics. + + TMDL import can keep warning/fallback behavior inside the TMDL adapter. Native + Sidemantic authoring is stricter: if a user explicitly marks an expression as + DAX, unsupported syntax should fail at model load time instead of becoming + accidental SQL. + """ + for model in graph.models.values(): + lower_dax_model_expressions(model, graph) + + for metric in graph.metrics.values(): + _lower_metric_dax(metric, graph, model=None) + + +def lower_dax_model_expressions(model: Model, graph: SemanticGraph) -> None: + """Lower DAX table, dimension, and metric expressions for a model.""" + _lower_model_table_dax(model, graph) + + for dimension in model.dimensions: + source = _dax_source(dimension) + if source is None: + continue + + expr = _parse_dax_expression(source, f"dimension '{model.name}.{dimension.name}'") + context = _build_dax_translation_context(graph) + try: + dimension.sql = translate_dax_scalar(expr, model.name, **_scalar_context(context)) + except DaxTranslationError as exc: + raise DaxModelingError(f"DAX dimension '{model.name}.{dimension.name}' is unsupported: {exc}") from exc + dimension.dax = source + dimension.expression_language = "dax" + setattr(dimension, "_dax_expression", source) + setattr(dimension, "_dax_lowered", True) + + # Lower metrics in declared order so simple base measures become available + # to later DAX metrics in the same model. + for metric in list(model.metrics): + _lower_metric_dax(metric, graph, model=model) + + +def _lower_model_table_dax(model: Model, graph: SemanticGraph) -> None: + source = _dax_source(model) + if source is None: + return + + expr = _parse_dax_expression(source, f"model '{model.name}'") + context = _build_dax_translation_context(graph) + try: + translation = translate_dax_table(expr, model_name=None, **_table_context(context)) + except DaxTranslationError as exc: + raise DaxModelingError(f"DAX model '{model.name}' is unsupported: {exc}") from exc + + model.sql = translation.sql + model.table = None + model.dax = source + model.expression_language = "dax" + setattr(model, "_dax_expression", source) + setattr(model, "_dax_lowered", True) + _append_modeling_warnings(graph, model, translation.warnings) + if translation.required_models: + setattr(model, "_dax_required_models", sorted(translation.required_models)) + + +def _lower_metric_dax(metric: Metric, graph: SemanticGraph, model: Model | None) -> None: + source = _dax_source(metric) + if source is None: + return + + context_name = f"metric '{metric.name}'" if model is None else f"metric '{model.name}.{metric.name}'" + expr = _parse_dax_expression(source, context_name) + context = _build_dax_translation_context(graph) + model_name = model.name if model is not None else _single_model_for_graph_metric(metric, graph) + try: + translation = translate_dax_metric(expr, model_name, **_metric_context(context)) + except DaxTranslationError as exc: + raise DaxModelingError(f"DAX {context_name} is unsupported: {exc}") from exc + + base_metric_ref = translation.base_metric + if ( + model is not None + and translation.type == "time_comparison" + and not base_metric_ref + and translation.inline_base_agg + and translation.inline_base_sql + ): + existing_names = {candidate.name for candidate in model.metrics} + base_name = _inline_base_metric_name(metric.name, existing_names) + model.metrics.append( + Metric( + name=base_name, + agg=translation.inline_base_agg, + sql=translation.inline_base_sql, + filters=translation.inline_base_filters or None, + ) + ) + base_metric_ref = f"{model.name}.{base_name}" + + metric.dax = source + metric.expression_language = "dax" + metric.agg = translation.agg + metric.sql = translation.sql + metric.type = translation.type + if metric.type is None and translation.sql and not translation.agg: + metric.type = "derived" + metric.base_metric = base_metric_ref + metric.comparison_type = translation.comparison_type + metric.calculation = translation.calculation + metric.time_offset = translation.time_offset + metric.window = translation.window + metric.grain_to_date = translation.grain_to_date + metric.window_order = translation.window_order + metric.filters = translation.filters or None + metric.relationship_overrides = translation.relationship_overrides or None + metric.required_models = sorted(translation.required_models) if translation.required_models else None + setattr(metric, "_dax_expression", source) + setattr(metric, "_dax_lowered", True) + + +def _single_model_for_graph_metric(metric: Metric, graph: SemanticGraph) -> str: + if len(graph.models) == 1: + return next(iter(graph.models)) + raise DaxModelingError( + f"DAX graph metric '{metric.name}' needs a model context; define it under a model or qualify it later" + ) + + +def _dax_source(obj: Any) -> str | None: + if bool(getattr(obj, "_dax_skip_native_lowering", False)): + return None + if bool(getattr(obj, "_dax_lowered", False)): + return None + + dax = getattr(obj, "dax", None) + language = getattr(obj, "expression_language", None) + + if isinstance(dax, str) and dax.strip(): + if language == "sql": + raise DaxModelingError( + f"{obj.__class__.__name__} defines dax but expression_language='sql'; " + "set expression_language='dax' or remove expression_language" + ) + return dax + if language == "dax": + sql = getattr(obj, "sql", None) + if isinstance(sql, str) and sql.strip(): + return sql + raise DaxModelingError(f"{obj.__class__.__name__} uses expression_language='dax' but has no dax/sql text") + return None + + +def _append_modeling_warnings(graph: SemanticGraph, model: Model, warnings: list[dict[str, Any]]) -> None: + if not warnings: + return + existing = getattr(graph, "import_warnings", None) + if not isinstance(existing, list): + existing = [] + graph.import_warnings = existing + for warning in warnings: + if not isinstance(warning, dict): + continue + existing.append( + { + "code": str(warning.get("code") or "dax_translation_warning"), + "context": "calculated_table", + "model": model.name, + "name": model.name, + "message": str(warning.get("message") or "DAX translation warning"), + } + ) + + +def _parse_dax_expression(source: str, context: str) -> Any: + try: + from sidemantic_dax.ast import parse_expression + except Exception as exc: + raise DaxModelingError( + "sidemantic_dax is required for DAX model definitions. Install DAX extras and retry " + "(e.g. `uv sync --extra dax`)." + ) from exc + + try: + return parse_expression(source) + except Exception as exc: + raise DaxModelingError(f"Could not parse DAX {context}: {exc}") from exc + + +def _metric_context(context: dict[str, Any]) -> dict[str, Any]: + return { + "column_sql_by_table": context["column_sql_by_table"], + "measure_names_by_table": context["measure_names_by_table"], + "measure_aggs_by_table": context["measure_aggs_by_table"], + "measure_sql_by_table": context["measure_sql_by_table"], + "measure_filters_by_table": context["measure_filters_by_table"], + "time_dimensions_by_table": context["time_dimensions_by_table"], + "relationship_edges": context["relationship_edges"], + } + + +def _scalar_context(context: dict[str, Any]) -> dict[str, Any]: + return { + "column_sql_by_table": context["column_sql_by_table"], + "measure_names_by_table": context["measure_names_by_table"], + "time_dimensions_by_table": context["time_dimensions_by_table"], + } + + +def _table_context(context: dict[str, Any]) -> dict[str, Any]: + return { + "column_sql_by_table": context["column_sql_by_table"], + "measure_names_by_table": context["measure_names_by_table"], + "relationship_edges": context["relationship_edges"], + } + + +def _inline_base_metric_name(metric_name: str, existing_names: set[str]) -> str: + stem_chars: list[str] = [] + for ch in metric_name: + stem_chars.append(ch.lower() if ch.isalnum() else "_") + stem = "".join(stem_chars).strip("_") or "metric" + candidate = f"__{stem}_base" + if candidate not in existing_names: + return candidate + idx = 2 + while f"{candidate}_{idx}" in existing_names: + idx += 1 + return f"{candidate}_{idx}" + + +def _build_dax_translation_context(graph: SemanticGraph) -> dict[str, Any]: + column_sql_by_table: dict[str, dict[str, str]] = {} + measure_names_by_table: dict[str, set[str]] = {} + measure_aggs_by_table: dict[str, dict[str, str]] = {} + measure_sql_by_table: dict[str, dict[str, str]] = {} + measure_filters_by_table: dict[str, dict[str, list[str]]] = {} + time_dimensions_by_table: dict[str, set[str]] = {} + + for model_name, model in graph.models.items(): + column_sql_by_table[model_name] = {dim.name: (dim.sql or dim.name) for dim in model.dimensions} + measure_names_by_table[model_name] = {metric.name for metric in model.metrics} + measure_aggs_by_table[model_name] = { + metric.name: metric.agg for metric in model.metrics if metric.agg and not _is_unlowered_dax_metric(metric) + } + measure_sql_by_table[model_name] = { + metric.name: metric.sql for metric in model.metrics if metric.sql and not _is_unlowered_dax_metric(metric) + } + measure_filters_by_table[model_name] = { + metric.name: list(metric.filters or []) + for metric in model.metrics + if metric.filters and not _is_unlowered_dax_metric(metric) + } + time_dimensions_by_table[model_name] = {dim.name for dim in model.dimensions if dim.type == "time"} + + edges: list[RelationshipEdge] = [] + seen_edges: set[tuple[str, str, str, str]] = set() + for model_name, model in graph.models.items(): + for rel in model.relationships: + if not rel.active: + continue + related_model = graph.models.get(rel.name) + if related_model is None: + continue + + if rel.type == "many_to_one": + from_column = rel.foreign_key or rel.sql_expr + to_column = rel.primary_key or related_model.primary_key + elif rel.type in ("one_to_many", "one_to_one"): + from_column = _relationship_tmdl_from_column(rel) or model.primary_key + to_column = rel.foreign_key or rel.sql_expr + elif rel.type == "many_to_many": + from_column = rel.foreign_key or rel.sql_expr + to_column = rel.primary_key or related_model.primary_key + else: + continue + + if not from_column or not to_column or not isinstance(from_column, str) or not isinstance(to_column, str): + continue + + key = (model_name.lower(), from_column.lower(), related_model.name.lower(), to_column.lower()) + reverse = (related_model.name.lower(), to_column.lower(), model_name.lower(), from_column.lower()) + if key in seen_edges or reverse in seen_edges: + continue + + seen_edges.add(key) + edges.append( + RelationshipEdge( + from_table=model_name, + from_column=from_column, + to_table=related_model.name, + to_column=to_column, + ) + ) + + return { + "column_sql_by_table": column_sql_by_table, + "measure_names_by_table": measure_names_by_table, + "measure_aggs_by_table": measure_aggs_by_table, + "measure_sql_by_table": measure_sql_by_table, + "measure_filters_by_table": measure_filters_by_table, + "time_dimensions_by_table": time_dimensions_by_table, + "relationship_edges": edges, + } + + +def _relationship_tmdl_from_column(rel: Any) -> str | None: + value = getattr(rel, "_tmdl_from_column", None) + if isinstance(value, str) and value.strip(): + return value + return None + + +def _is_unlowered_dax_metric(metric: Metric) -> bool: + return metric.expression_language == "dax" and not bool(getattr(metric, "_dax_lowered", False)) diff --git a/sidemantic/dax/runtime.py b/sidemantic/dax/runtime.py new file mode 100644 index 00000000..cd659669 --- /dev/null +++ b/sidemantic/dax/runtime.py @@ -0,0 +1,110 @@ +"""Runtime helpers for parsing/translating DAX against a semantic graph.""" + +from __future__ import annotations + +from typing import Any + +from sidemantic.core.semantic_graph import SemanticGraph +from sidemantic.dax import RelationshipEdge, translate_dax_query + + +def parse_dax_query(text: str) -> Any: + """Parse raw DAX query text into sidemantic_dax AST.""" + try: + import sidemantic_dax + except Exception as exc: + raise RuntimeError( + "sidemantic_dax is required for DAX query execution. Install DAX extras and retry (e.g. `uv sync --extra dax`)." + ) from exc + + try: + return sidemantic_dax.parse_query(text) + except RuntimeError as exc: + if "native module is not available" in str(exc): + raise RuntimeError( + "sidemantic_dax native module is not available. Rebuild/install DAX extras (e.g. `uv sync --extra dax`)." + ) from exc + raise + + +def build_dax_translation_context(graph: SemanticGraph) -> dict[str, Any]: + """Build query translation context from graph models/relationships.""" + column_sql_by_table: dict[str, dict[str, str]] = {} + measure_names_by_table: dict[str, set[str]] = {} + measure_aggs_by_table: dict[str, dict[str, str]] = {} + measure_sql_by_table: dict[str, dict[str, str]] = {} + measure_filters_by_table: dict[str, dict[str, list[str]]] = {} + time_dimensions_by_table: dict[str, set[str]] = {} + + for model_name, model in graph.models.items(): + column_sql_by_table[model_name] = {dim.name: (dim.sql or dim.name) for dim in model.dimensions} + measure_names_by_table[model_name] = {metric.name for metric in model.metrics} + measure_aggs_by_table[model_name] = {metric.name: metric.agg for metric in model.metrics if metric.agg} + measure_sql_by_table[model_name] = {metric.name: metric.sql for metric in model.metrics if metric.sql} + measure_filters_by_table[model_name] = { + metric.name: list(metric.filters or []) for metric in model.metrics if metric.filters + } + time_dimensions_by_table[model_name] = {dim.name for dim in model.dimensions if dim.type == "time"} + + edges: list[RelationshipEdge] = [] + seen_edges: set[tuple[str, str, str, str]] = set() + for model_name, model in graph.models.items(): + for rel in model.relationships: + if not rel.active: + continue + related_model = graph.models.get(rel.name) + if related_model is None: + continue + + if rel.type == "many_to_one": + from_column = rel.foreign_key or rel.sql_expr + to_column = rel.primary_key or related_model.primary_key + elif rel.type in ("one_to_many", "one_to_one"): + from_column = _relationship_tmdl_from_column(rel) or model.primary_key + to_column = rel.foreign_key or rel.sql_expr + elif rel.type == "many_to_many": + from_column = rel.foreign_key or rel.sql_expr + to_column = rel.primary_key or related_model.primary_key + else: + continue + + if not from_column or not to_column: + continue + + key = (model_name.lower(), from_column.lower(), related_model.name.lower(), to_column.lower()) + reverse = (related_model.name.lower(), to_column.lower(), model_name.lower(), from_column.lower()) + if key in seen_edges or reverse in seen_edges: + continue + + seen_edges.add(key) + edges.append( + RelationshipEdge( + from_table=model_name, + from_column=from_column, + to_table=related_model.name, + to_column=to_column, + ) + ) + + return { + "column_sql_by_table": column_sql_by_table, + "measure_names_by_table": measure_names_by_table, + "measure_aggs_by_table": measure_aggs_by_table, + "measure_sql_by_table": measure_sql_by_table, + "measure_filters_by_table": measure_filters_by_table, + "time_dimensions_by_table": time_dimensions_by_table, + "relationship_edges": edges, + } + + +def _relationship_tmdl_from_column(rel: Any) -> str | None: + value = getattr(rel, "_tmdl_from_column", None) + if isinstance(value, str) and value.strip(): + return value + return None + + +def translate_dax_query_ast(query_ast: Any, graph: SemanticGraph) -> Any: + """Translate parsed DAX query AST into executable SQL payloads.""" + context = build_dax_translation_context(graph) + return translate_dax_query(query_ast, **context) diff --git a/sidemantic/dax/translator.py b/sidemantic/dax/translator.py new file mode 100644 index 00000000..1f8d6bcd --- /dev/null +++ b/sidemantic/dax/translator.py @@ -0,0 +1,7367 @@ +"""Translate DAX AST into Sidemantic-friendly SQL and metadata.""" + +from __future__ import annotations + +import re +from collections import deque +from collections.abc import Iterable +from contextlib import nullcontext +from dataclasses import dataclass, field +from typing import Any + +from sidemantic.core.relationship import RelationshipOverride + + +class DaxTranslationError(ValueError): + pass + + +@dataclass(frozen=True) +class ColumnRef: + table: str | None + column: str + + +@dataclass(frozen=True) +class FilterClause: + sql: str + columns: frozenset[ColumnRef] + keep: bool = False + + +@dataclass(frozen=True) +class FilterRemoval: + table: str | None = None + column: str | None = None + + +@dataclass(frozen=True) +class FilterRetention: + table: str + columns: frozenset[str] + + +@dataclass +class MetricTranslation: + sql: str | None + agg: str | None = None + type: str | None = None + source_table: str | None = None + base_metric: str | None = None + inline_base_sql: str | None = None + inline_base_agg: str | None = None + inline_base_filters: list[str] = field(default_factory=list) + comparison_type: str | None = None + calculation: str | None = None + time_offset: str | None = None + window: str | None = None + grain_to_date: str | None = None + window_order: str | None = None + filters: list[str] = field(default_factory=list) + relationship_overrides: list[RelationshipOverride] = field(default_factory=list) + required_models: set[str] = field(default_factory=set) + + +@dataclass +class TableTranslation: + sql: str + required_models: set[str] = field(default_factory=set) + warnings: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class QueryEvaluateTranslation: + sql: str + required_models: set[str] = field(default_factory=set) + warnings: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class QueryTranslation: + evaluates: list[QueryEvaluateTranslation] + warnings: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class RelationshipEdge: + from_table: str + from_column: str + to_table: str + to_column: str + + +@dataclass(frozen=True) +class TableColumnArg: + name: str + table: str | None = None + + +def translate_dax_metric( + expr: Any, + model_name: str, + column_sql_by_table: dict[str, dict[str, str]] | None = None, + measure_names_by_table: dict[str, set[str]] | None = None, + measure_aggs_by_table: dict[str, dict[str, str]] | None = None, + measure_sql_by_table: dict[str, dict[str, str]] | None = None, + measure_filters_by_table: dict[str, dict[str, list[str]]] | None = None, + time_dimensions_by_table: dict[str, set[str]] | None = None, + relationship_edges: list[RelationshipEdge] | None = None, +) -> MetricTranslation: + dax_ast = _load_dax_ast() + translator = _DaxTranslator( + dax_ast, + model_name=model_name, + column_sql_by_table=column_sql_by_table or {}, + measure_names_by_table=measure_names_by_table or {}, + measure_aggs_by_table=measure_aggs_by_table or {}, + measure_sql_by_table=measure_sql_by_table or {}, + measure_filters_by_table=measure_filters_by_table or {}, + time_dimensions_by_table=time_dimensions_by_table or {}, + relationship_edges=relationship_edges or [], + ) + return translator.translate_metric(expr) + + +def translate_dax_table( + expr: Any, + model_name: str | None = None, + column_sql_by_table: dict[str, dict[str, str]] | None = None, + measure_names_by_table: dict[str, set[str]] | None = None, + relationship_edges: list[RelationshipEdge] | None = None, +) -> TableTranslation: + dax_ast = _load_dax_ast() + translator = _DaxTranslator( + dax_ast, + model_name=model_name, + column_sql_by_table=column_sql_by_table or {}, + measure_names_by_table=measure_names_by_table or {}, + measure_aggs_by_table={}, + measure_sql_by_table={}, + measure_filters_by_table={}, + time_dimensions_by_table={}, + relationship_edges=relationship_edges or [], + allow_unrelated_table_cross_join=True, + ) + return translator.translate_table(expr) + + +def translate_dax_scalar( + expr: Any, + model_name: str | None = None, + column_sql_by_table: dict[str, dict[str, str]] | None = None, + measure_names_by_table: dict[str, set[str]] | None = None, + time_dimensions_by_table: dict[str, set[str]] | None = None, +) -> str: + dax_ast = _load_dax_ast() + translator = _DaxTranslator( + dax_ast, + model_name=model_name, + column_sql_by_table=column_sql_by_table or {}, + measure_names_by_table=measure_names_by_table or {}, + measure_aggs_by_table={}, + measure_sql_by_table={}, + measure_filters_by_table={}, + time_dimensions_by_table=time_dimensions_by_table or {}, + relationship_edges=[], + ) + context = translator._allow_cross_table_context() if model_name is None else nullcontext() + with context: + return translator._translate_scalar(expr).sql + + +def translate_dax_query( + query: Any, + column_sql_by_table: dict[str, dict[str, str]] | None = None, + measure_names_by_table: dict[str, set[str]] | None = None, + measure_aggs_by_table: dict[str, dict[str, str]] | None = None, + measure_sql_by_table: dict[str, dict[str, str]] | None = None, + measure_filters_by_table: dict[str, dict[str, list[str]]] | None = None, + time_dimensions_by_table: dict[str, set[str]] | None = None, + relationship_edges: list[RelationshipEdge] | None = None, +) -> QueryTranslation: + dax_ast = _load_dax_ast() + if not isinstance(query, dax_ast.Query): + raise DaxTranslationError("translate_dax_query expects sidemantic_dax.ast.Query") + + resolver = _DefineResolver(dax_ast, query.define) + evaluates: list[QueryEvaluateTranslation] = [] + for stmt in query.evaluates: + translator = _DaxTranslator( + dax_ast, + model_name=None, + column_sql_by_table=column_sql_by_table or {}, + measure_names_by_table=measure_names_by_table or {}, + measure_aggs_by_table=measure_aggs_by_table or {}, + measure_sql_by_table=measure_sql_by_table or {}, + measure_filters_by_table=measure_filters_by_table or {}, + time_dimensions_by_table=time_dimensions_by_table or {}, + relationship_edges=relationship_edges or [], + allow_unrelated_table_cross_join=True, + ) + statement_expr = resolver.resolve_expr(stmt.expr) + sql = translator._translate_table(statement_expr) + order_keys = _translate_order_keys(stmt, translator, resolver) + sql = _apply_order_and_start_at(sql, stmt, translator, resolver, order_keys) + evaluates.append( + QueryEvaluateTranslation( + sql=sql, + required_models=set(translator._required_models), + warnings=translator.warnings, + ) + ) + + return QueryTranslation(evaluates=evaluates) + + +class _DaxTranslator: + def __init__( + self, + dax_ast: Any, + model_name: str | None, + column_sql_by_table: dict[str, dict[str, str]], + measure_names_by_table: dict[str, set[str]], + measure_aggs_by_table: dict[str, dict[str, str]], + measure_sql_by_table: dict[str, dict[str, str]], + measure_filters_by_table: dict[str, dict[str, list[str]]], + time_dimensions_by_table: dict[str, set[str]], + relationship_edges: list[RelationshipEdge], + allow_unrelated_table_cross_join: bool = False, + ) -> None: + self.dax = dax_ast + self.model_name = model_name + self.column_sql_by_table = column_sql_by_table + self.measure_names_by_table = measure_names_by_table + self.measure_aggs_by_table = measure_aggs_by_table + self.measure_sql_by_table = measure_sql_by_table + self.measure_filters_by_table = measure_filters_by_table + self.time_dimensions_by_table = time_dimensions_by_table + self._env: dict[str, _SqlFragment] = {} + self._required_models: set[str] = set() + self._relationship_overrides: list[RelationshipOverride] = [] + self._base_table: str | None = None + self._allow_cross_table = False + self._prefer_unqualified_base_table = False + self._relationship_edges = relationship_edges + self._relationship_adjacency = self._build_relationship_adjacency(relationship_edges) + self._allow_unrelated_table_cross_join = allow_unrelated_table_cross_join + self._current_group_by_columns: frozenset[ColumnRef] = frozenset() + self._current_filter_columns: frozenset[ColumnRef] = frozenset() + self._warnings: list[dict[str, Any]] = [] + self._warning_keys: set[tuple[str, str, str]] = set() + + @property + def warnings(self) -> list[dict[str, Any]]: + return [dict(warning) for warning in self._warnings] + + def translate_metric(self, expr: Any) -> MetricTranslation: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.VarBlock): + return self._translate_var_block_metric(expr) + + metric_ref = self._translate_metric_reference(expr) + if metric_ref is not None: + return metric_ref + + if isinstance(expr, self.dax.FunctionCall): + name = expr.name.lower() + if name == "calculate": + return self._translate_calculate(expr) + if name in ("totalytd", "totalmtd", "totalqtd", "totalwtd"): + return self._translate_total_to_date(expr) + if name in ("min", "max"): + if len(expr.args) == 1: + return self._translate_aggregate(expr) + with self._allow_cross_table_context(): + sql = self._translate_min_max(expr).sql + return MetricTranslation(sql=sql, type="derived", required_models=set(self._required_models)) + if name in ( + "sum", + "average", + "averagea", + "avg", + "mina", + "maxa", + "median", + "count", + "countrows", + "counta", + "countblank", + "distinctcount", + "distinctcountnoblank", + "approximatedistinctcount", + ): + return self._translate_aggregate(expr) + if name in ("sumx", "averagex", "avgx", "minx", "maxx", "medianx", "countx", "countax"): + return self._translate_iter_aggregate(expr) + + if isinstance(expr, self.dax.Paren): + return self.translate_metric(expr.expr) + + with self._allow_cross_table_context(): + sql = self._translate_scalar(expr).sql + return MetricTranslation(sql=sql, type="derived", required_models=set(self._required_models)) + + def _translate_var_block_metric(self, var_block: Any) -> MetricTranslation: + prior_env = dict(self._env) + metric_vars: dict[str, MetricTranslation] = {} + + try: + for decl in var_block.decls: + metric_value = None + try: + metric_value = self.translate_metric(decl.expr) + except DaxTranslationError: + metric_value = None + if metric_value is not None: + metric_vars[decl.name.lower()] = metric_value + + try: + with self._allow_cross_table_context(): + scalar_value = self._translate_scalar(decl.expr) + except DaxTranslationError: + # Keep metric-only vars available for RETURN var metric paths. + # Some metric vars (for example time-intelligence CALCULATE) do not map to scalar SQL. + if metric_value is not None: + continue + raise + self._env[decl.name.lower()] = scalar_value + + body = self._unwrap(var_block.body) + if isinstance(body, self.dax.Identifier): + key = body.name.lower() + if key in metric_vars: + result = metric_vars[key] + result.required_models.update(self._required_models) + return result + if isinstance(body, self.dax.BracketRef): + key = body.name.lower() + if key in metric_vars: + result = metric_vars[key] + result.required_models.update(self._required_models) + return result + + return MetricTranslation( + sql=self._translate_projection_scalar(var_block.body).sql, + type="derived", + required_models=set(self._required_models), + ) + finally: + self._env = prior_env + + def translate_table(self, expr: Any) -> TableTranslation: + sql = self._translate_table(expr) + return TableTranslation(sql=sql, required_models=set(self._required_models), warnings=self.warnings) + + def _translate_calculate(self, call: Any) -> MetricTranslation: + if not call.args: + raise DaxTranslationError("CALCULATE requires an expression") + + base_expr = call.args[0] + filter_args = call.args[1:] + + time_translation = self._translate_calculate_time_intelligence(base_expr, filter_args) + if time_translation is not None: + return time_translation + + base_metric = self.translate_metric(base_expr) + + new_filters, removals, retentions, remove_all, clear_non_keep, overrides = self._translate_filter_args( + filter_args + ) + inherited_filters = [self._filter_clause_from_sql(sql, keep=True) for sql in (base_metric.filters or [])] + combined_filters = self._merge_filter_clauses(inherited_filters, new_filters) + combined_filters = self._apply_non_keep_clear(combined_filters, remove_all, clear_non_keep) + filters = self._apply_filter_retentions(combined_filters, retentions, remove_all) + filters = self._apply_filter_removals(filters, removals, remove_all) + + base_metric.relationship_overrides.extend(overrides) + base_metric.required_models.update(self._required_models) + base_metric.filters = [] + + if base_metric.type in ("time_comparison", "cumulative"): + if filters: + base_metric.filters.extend(filters) + return base_metric + + if base_metric.agg: + base_metric.filters.extend(filters) + return base_metric + + if filters and base_metric.sql: + predicate = " AND ".join(filters) + base_metric.sql = f"CASE WHEN {predicate} THEN {base_metric.sql} ELSE NULL END" + return base_metric + + def _translate_calculate_time_intelligence( + self, base_expr: Any, filter_args: list[Any] + ) -> MetricTranslation | None: + if not filter_args: + return None + + time_filter_idx: int | None = None + time_filter: Any | None = None + for idx, arg in enumerate(filter_args): + candidate = self._extract_time_filter_call(arg) + if candidate is None: + continue + time_filter_idx = idx + time_filter = candidate + break + + if time_filter is None or time_filter_idx is None: + return None + + name = time_filter.name.lower() + if time_filter.args: + with self._allow_cross_table_context(): + window_order = self._translate_scalar(time_filter.args[0]).sql + else: + window_order = None + base_metric = self._extract_measure_reference(base_expr) + inline_base_sql = None + inline_base_agg = None + inline_base_filters: list[str] = [] + if not base_metric: + base_translation = self.translate_metric(base_expr) + if not base_translation.agg or not base_translation.sql: + return None + inline_base_sql = base_translation.sql + inline_base_agg = base_translation.agg + inline_base_filters = list(base_translation.filters or []) + + if name in ("datesytd", "datesmtd", "datesqtd", "dateswtd"): + self._validate_time_argument(time_filter.args[0] if time_filter.args else None) + grain = { + "datesytd": "year", + "datesmtd": "month", + "datesqtd": "quarter", + "dateswtd": "week", + }[name] + + base_translation = self.translate_metric(base_expr) + if base_translation.agg: + translation = MetricTranslation( + sql=base_translation.sql, + agg=base_translation.agg, + type="cumulative", + grain_to_date=grain, + window_order=window_order, + required_models=set(self._required_models), + ) + else: + base_ref = base_translation.base_metric or self._extract_measure_reference(base_expr) + if base_ref: + base_agg = self._lookup_measure_agg(base_ref) + translation = MetricTranslation( + sql=base_ref, + agg=base_agg, + type="cumulative", + grain_to_date=grain, + window_order=window_order, + required_models=set(self._required_models), + ) + elif base_translation.sql: + translation = MetricTranslation( + sql=base_translation.sql, + agg=base_translation.agg, + type="cumulative", + grain_to_date=grain, + window_order=window_order, + required_models=set(self._required_models), + ) + else: + return None + + translation.relationship_overrides = list(base_translation.relationship_overrides or []) + translation.required_models.update(base_translation.required_models) + if base_translation.filters: + translation.filters = list(base_translation.filters) + elif name == "datesinperiod": + self._validate_time_argument(time_filter.args[0] if time_filter.args else None) + if len(time_filter.args) < 4: + return None + periods = self._number_literal_value(time_filter.args[2]) + unit = self._identifier_literal_value(time_filter.args[3]) + if periods is None or unit is None: + return None + + normalized_unit = unit.lower() + if normalized_unit.endswith("s"): + normalized_unit = normalized_unit[:-1] + if periods > 0: + window = f"{periods} {normalized_unit} following" + else: + window = f"{abs(periods)} {normalized_unit}" + + base_translation = self.translate_metric(base_expr) + if base_translation.agg: + translation = MetricTranslation( + sql=base_translation.sql, + agg=base_translation.agg, + type="cumulative", + window=window, + window_order=window_order, + required_models=set(self._required_models), + ) + else: + base_ref = base_translation.base_metric or self._extract_measure_reference(base_expr) + if base_ref: + base_agg = self._lookup_measure_agg(base_ref) + translation = MetricTranslation( + sql=base_ref, + agg=base_agg, + type="cumulative", + window=window, + window_order=window_order, + required_models=set(self._required_models), + ) + elif base_translation.sql: + translation = MetricTranslation( + sql=base_translation.sql, + agg=base_translation.agg, + type="cumulative", + window=window, + window_order=window_order, + required_models=set(self._required_models), + ) + else: + return None + + translation.relationship_overrides = list(base_translation.relationship_overrides or []) + translation.required_models.update(base_translation.required_models) + if base_translation.filters: + translation.filters = list(base_translation.filters) + elif name == "sameperiodlastyear": + self._validate_time_argument(time_filter.args[0] if time_filter.args else None) + translation = MetricTranslation( + sql=None, + type="time_comparison", + base_metric=base_metric, + inline_base_sql=inline_base_sql, + inline_base_agg=inline_base_agg, + inline_base_filters=inline_base_filters, + comparison_type="yoy", + calculation="previous_value", + window_order=window_order, + required_models=set(self._required_models), + ) + elif name in ("dateadd", "parallelperiod"): + self._validate_time_argument(time_filter.args[0] if time_filter.args else None) + time_info = self._parse_dateadd(time_filter) + if not time_info: + return None + offset, unit = time_info + comparison_type = _comparison_type_for_unit(unit) + translation = MetricTranslation( + sql=None, + type="time_comparison", + base_metric=base_metric, + inline_base_sql=inline_base_sql, + inline_base_agg=inline_base_agg, + inline_base_filters=inline_base_filters, + comparison_type=comparison_type, + calculation="previous_value", + time_offset=f"{offset} {unit}", + window_order=window_order, + required_models=set(self._required_models), + ) + else: + self._validate_time_argument(time_filter.args[0] if time_filter.args else None) + time_info = _time_offset_for_period_function(name) + if time_info is None: + return None + offset, unit = time_info + comparison_type = _comparison_type_for_unit(unit) + translation = MetricTranslation( + sql=None, + type="time_comparison", + base_metric=base_metric, + inline_base_sql=inline_base_sql, + inline_base_agg=inline_base_agg, + inline_base_filters=inline_base_filters, + comparison_type=comparison_type, + calculation="previous_value", + time_offset=f"{offset} {unit}", + window_order=window_order, + required_models=set(self._required_models), + ) + + remaining = [arg for idx, arg in enumerate(filter_args) if idx != time_filter_idx] + if not remaining: + return translation + + new_filters, removals, retentions, remove_all, clear_non_keep, overrides = self._translate_filter_args( + remaining + ) + combined_filters = self._merge_filter_clauses([], new_filters) + combined_filters = self._apply_non_keep_clear(combined_filters, remove_all, clear_non_keep) + retained_filters = self._apply_filter_retentions(combined_filters, retentions, remove_all) + translation.filters = self._apply_filter_removals(retained_filters, removals, remove_all) + translation.relationship_overrides.extend(overrides) + translation.required_models.update(self._required_models) + return translation + + return None + + def _extract_time_filter_call(self, expr: Any) -> Any | None: + candidate = self._unwrap(expr) + if not isinstance(candidate, self.dax.FunctionCall): + return None + + name = candidate.name.lower() + if name in ( + "sameperiodlastyear", + "dateadd", + "parallelperiod", + "datesytd", + "datesmtd", + "datesqtd", + "dateswtd", + "datesinperiod", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + ): + return candidate + + if name == "keepfilters" and candidate.args: + inner = self._unwrap(candidate.args[0]) + if isinstance(inner, self.dax.FunctionCall) and inner.name.lower() in ( + "sameperiodlastyear", + "dateadd", + "parallelperiod", + "datesytd", + "datesmtd", + "datesqtd", + "dateswtd", + "datesinperiod", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + ): + return inner + + return None + + def _translate_total_to_date(self, call: Any) -> MetricTranslation: + if not call.args: + raise DaxTranslationError("TOTAL* function requires an expression") + if len(call.args) > 1: + self._validate_time_argument(call.args[1]) + if len(call.args) > 1: + with self._allow_cross_table_context(): + window_order = self._translate_scalar(call.args[1]).sql + else: + window_order = None + + extra_filter_args: list[Any] = [] + if len(call.args) > 2: + for arg in call.args[2:]: + # TOTALYTD optionally accepts a year-end literal after filter args. + if isinstance(self._unwrap(arg), self.dax.String): + continue + extra_filter_args.append(arg) + + base_expr = call.args[0] + grain = { + "totalytd": "year", + "totalmtd": "month", + "totalqtd": "quarter", + "totalwtd": "week", + }.get(call.name.lower()) + + base_metric = self.translate_metric(base_expr) + translation: MetricTranslation + if base_metric.agg: + translation = MetricTranslation( + sql=base_metric.sql, + agg=base_metric.agg, + type="cumulative", + grain_to_date=grain, + window_order=window_order, + required_models=set(self._required_models), + ) + else: + base_ref = base_metric.base_metric or self._extract_measure_reference(base_expr) + if base_ref: + base_agg = self._lookup_measure_agg(base_ref) + translation = MetricTranslation( + sql=base_ref, + agg=base_agg, + type="cumulative", + grain_to_date=grain, + window_order=window_order, + required_models=set(self._required_models), + ) + elif base_metric.sql: + translation = MetricTranslation( + sql=base_metric.sql, + agg=base_metric.agg, + type="cumulative", + grain_to_date=grain, + window_order=window_order, + required_models=set(self._required_models), + ) + else: + raise DaxTranslationError("Unsupported TOTAL* expression") + + inherited_filters = [self._filter_clause_from_sql(sql, keep=True) for sql in (base_metric.filters or [])] + ( + new_filters, + removals, + retentions, + remove_all, + clear_non_keep, + overrides, + ) = self._translate_filter_args(extra_filter_args) + combined_filters = self._merge_filter_clauses(inherited_filters, new_filters) + combined_filters = self._apply_non_keep_clear(combined_filters, remove_all, clear_non_keep) + retained_filters = self._apply_filter_retentions(combined_filters, retentions, remove_all) + translation.filters = self._apply_filter_removals(retained_filters, removals, remove_all) + + translation.relationship_overrides = [*base_metric.relationship_overrides, *overrides] + translation.required_models.update(base_metric.required_models) + translation.required_models.update(self._required_models) + return translation + + def _translate_aggregate(self, call: Any) -> MetricTranslation: + name = call.name.lower() + agg_map = { + "sum": "sum", + "average": "avg", + "averagea": "avg", + "avg": "avg", + "min": "min", + "mina": "min", + "max": "max", + "maxa": "max", + "median": "median", + "count": "count", + "countrows": "count", + "counta": "count", + "countblank": "count", + "distinctcount": "count_distinct", + "distinctcountnoblank": "count_distinct", + "approximatedistinctcount": "count_distinct", + } + agg = agg_map.get(name) + if agg is None: + raise DaxTranslationError(f"Unsupported aggregate {call.name}") + + if name == "countrows": + if len(call.args) > 1: + raise DaxTranslationError("COUNTROWS supports at most one argument") + if call.args: + distinct_translation = self._translate_countrows_distinct_table(call.args[0]) + if distinct_translation is not None: + return distinct_translation + with self._allow_cross_table_context(): + filters, overrides = self._filters_from_table(call.args[0]) + return MetricTranslation( + sql=None, + agg=agg, + filters=filters, + relationship_overrides=overrides, + required_models=set(self._required_models), + ) + return MetricTranslation(sql=None, agg=agg, required_models=set(self._required_models)) + + if not call.args: + raise DaxTranslationError(f"{call.name} requires an argument") + if len(call.args) > 1: + raise DaxTranslationError(f"{call.name} supports exactly one argument") + + prefer_unqualified_base = self.model_name is None and not self._allow_cross_table + nested_context = self._prefer_unqualified_base_table_context() if prefer_unqualified_base else nullcontext() + with self._allow_cross_table_context(): + with nested_context: + arg_sql = self._translate_scalar(call.args[0]) + if name == "countblank": + return MetricTranslation( + sql=f"CASE WHEN {arg_sql.sql} IS NULL THEN 1 END", + agg=agg, + required_models=set(self._required_models), + ) + return MetricTranslation(sql=arg_sql.sql, agg=agg, required_models=set(self._required_models)) + + def _translate_countrows_distinct_table(self, table_expr: Any) -> MetricTranslation | None: + table_expr = self._unwrap(table_expr) + if not isinstance(table_expr, self.dax.FunctionCall): + return None + if table_expr.name.lower() not in ("values", "filters", "distinct"): + return None + if not table_expr.args: + return None + + target = self._unwrap(table_expr.args[0]) + if not isinstance( + target, + ( + self.dax.TableColumnRef, + self.dax.HierarchyRef, + self.dax.BracketRef, + self.dax.Identifier, + ), + ): + return None + + with self._allow_cross_table_context(): + column_sql = self._translate_scalar(target) + return MetricTranslation( + sql=column_sql.sql, + agg="count_distinct", + required_models=set(self._required_models), + ) + + def _translate_iter_aggregate(self, call: Any) -> MetricTranslation: + name = call.name.lower() + agg_map = { + "sumx": "sum", + "averagex": "avg", + "avgx": "avg", + "minx": "min", + "maxx": "max", + "medianx": "median", + "countx": "count", + "countax": "count", + } + agg = agg_map[name] + if len(call.args) < 2: + raise DaxTranslationError(f"{call.name} requires a table and expression") + + table_expr = call.args[0] + row_expr = call.args[1] + + with self._allow_cross_table_context(): + table_target = self._unwrap(table_expr) + if isinstance(table_target, self.dax.FunctionCall) and table_target.name.lower() == "currentgroup": + filters = [] + overrides = [] + else: + filters, overrides = self._filters_from_table(table_expr) + row_sql = self._translate_scalar(row_expr) + return MetricTranslation( + sql=row_sql.sql, + agg=agg, + filters=filters, + relationship_overrides=overrides, + required_models=set(self._required_models), + ) + + def _translate_filter_args( + self, args: Iterable[Any] + ) -> tuple[ + list[FilterClause], + list[FilterRemoval], + list[FilterRetention], + bool, + bool, + list[RelationshipOverride], + ]: + filters: list[FilterClause] = [] + removals: list[FilterRemoval] = [] + retentions: list[FilterRetention] = [] + remove_all = False + clear_non_keep = False + overrides: list[RelationshipOverride] = [] + + for arg in args: + arg = self._unwrap(arg) + if isinstance(arg, self.dax.FunctionCall): + name = arg.name.lower() + if name == "keepfilters": + inner = arg.args[0] if arg.args else None + if inner is None: + continue + candidate_filters, candidate_overrides = self._translate_filter_candidate(inner, keep=True) + filters.extend(candidate_filters) + overrides.extend(candidate_overrides) + continue + if name in ("removefilters", "all", "allnoblankrow", "allselected", "allcrossfiltered"): + if not arg.args: + if name == "allselected": + clear_non_keep = True + else: + remove_all = True + continue + for target in arg.args: + removal = self._translate_filter_removal(target) + if removal: + removals.append(removal) + continue + if name == "allexcept": + retention = self._translate_allexcept(arg) + if retention is not None: + retentions.append(retention) + continue + if name == "userelationship": + override = self._translate_userelationship(arg) + if override: + overrides.append(override) + continue + if name == "crossfilter": + override = self._translate_crossfilter(arg) + if override: + overrides.append(override) + continue + if name == "filter": + candidate_filters, candidate_overrides = self._translate_filter_candidate(arg, keep=False) + filters.extend(candidate_filters) + overrides.extend(candidate_overrides) + continue + candidate_filters, candidate_overrides = self._translate_filter_candidate(arg, keep=False) + filters.extend(candidate_filters) + overrides.extend(candidate_overrides) + + return filters, removals, retentions, remove_all, clear_non_keep, overrides + + def _translate_filter_candidate( + self, expr: Any, keep: bool + ) -> tuple[list[FilterClause], list[RelationshipOverride]]: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "nonvisual": + inner = self._unwrap(expr.args[0]) if expr.args else None + if inner is None: + return [], [] + return self._translate_filter_candidate(inner, keep=keep) + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "filter": + if len(expr.args) < 2: + raise DaxTranslationError("FILTER requires table and predicate") + source_expr = self._unwrap(expr.args[0]) + with self._allow_cross_table_context(): + table_filters_sql, table_overrides = self._filters_from_table(source_expr) + table_filters = [self._filter_clause_from_sql(sql, keep=keep) for sql in table_filters_sql] + predicate_clause = self._translate_predicate(expr.args[1], keep=keep) + alias_predicate = self._translate_alias_backed_filter_predicate(source_expr, expr.args[1], keep=keep) + if alias_predicate is not None: + return [*table_filters, alias_predicate], table_overrides + if self._table_name_from_expr(source_expr) is None and self._predicate_needs_derived_alias_fallback( + expr.args[1], predicate_clause + ): + with self._allow_cross_table_context(): + filtered_sql = self._translate_filter_table(expr) + exists_sql = f"EXISTS (SELECT 1 FROM ({filtered_sql}) AS __filter_table)" + # Treat derived-table alias predicates as opaque: avoid leaking inner table + # lineage into outer FROM expansion. + return [FilterClause(sql=exists_sql, columns=frozenset(), keep=keep)], table_overrides + return [*table_filters, predicate_clause], table_overrides + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "treatas": + return [self._translate_treatas_filter(expr, keep=keep)], [] + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "datesbetween": + clause = self._translate_datesbetween_filter(expr, keep=keep) + if clause is None: + return [], [] + return [clause], [] + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "datesinperiod": + clause = self._translate_datesinperiod_filter(expr, keep=keep) + if clause is None: + return [], [] + return [clause], [] + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() in ( + "datesytd", + "datesmtd", + "datesqtd", + "dateswtd", + ): + clause = self._translate_cumulative_period_filter(expr, keep=keep) + if clause is None: + return [], [] + return [clause], [] + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() in ( + "sameperiodlastyear", + "dateadd", + "parallelperiod", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + ): + clause = self._translate_relative_period_filter(expr, keep=keep) + if clause is None: + return [], [] + return [clause], [] + if isinstance(expr, self.dax.FunctionCall) and self._is_filter_table_candidate(expr): + with self._allow_cross_table_context(): + table_filters_sql, table_overrides = self._filters_from_table(expr) + table_filters = [self._filter_clause_from_sql(sql, keep=keep) for sql in table_filters_sql] + return table_filters, table_overrides + if self._is_table_filter_candidate_expr(expr): + with self._allow_cross_table_context(): + table_filters_sql, table_overrides = self._filters_from_table(expr) + table_filters = [self._filter_clause_from_sql(sql, keep=keep) for sql in table_filters_sql] + return table_filters, table_overrides + + return [self._translate_predicate(expr, keep=keep)], [] + + def _translate_treatas_filter(self, call: Any, keep: bool) -> FilterClause: + if len(call.args) < 2: + raise DaxTranslationError("TREATAS requires a table expression and at least one target column") + + source_expr = self._unwrap(call.args[0]) + target_exprs = [self._unwrap(arg) for arg in call.args[1:]] + + with self._allow_cross_table_context(): + target_fragments: list[_SqlFragment] = [self._translate_scalar(expr) for expr in target_exprs] + + target_sql = [fragment.sql for fragment in target_fragments] + target_columns: set[ColumnRef] = set() + for fragment in target_fragments: + target_columns.update(fragment.columns) + + if isinstance(source_expr, self.dax.TableConstructor): + if not source_expr.rows: + return FilterClause(sql="(1 = 0)", columns=frozenset(target_columns), keep=keep) + + width = len(source_expr.rows[0]) + if width != len(target_sql): + raise DaxTranslationError("TREATAS table column count must match target column count") + + values_sql: list[str] = [] + for row in source_expr.rows: + if len(row) != width: + raise DaxTranslationError("Table constructor rows must have the same number of values") + fragments = [self._translate_scalar(value) for value in row] + if width == 1: + values_sql.append(fragments[0].sql) + else: + values_sql.append("(" + ", ".join(fragment.sql for fragment in fragments) + ")") + + if width == 1: + predicate = f"{target_sql[0]} IN ({', '.join(values_sql)})" + else: + predicate = f"({', '.join(target_sql)}) IN ({', '.join(values_sql)})" + return FilterClause(sql=f"({predicate})", columns=frozenset(target_columns), keep=keep) + + if isinstance(source_expr, self.dax.FunctionCall) or self._table_name_from_expr(source_expr) is not None: + source_sql = self._translate_table(source_expr) + source_width = self._treatas_source_width(source_expr, source_sql) + if source_width is not None and source_width != len(target_sql): + raise DaxTranslationError("TREATAS table column count must match target column count") + left = target_sql[0] if len(target_sql) == 1 else f"({', '.join(target_sql)})" + predicate = f"{left} IN (SELECT * FROM ({source_sql}) AS treatas_values)" + return FilterClause(sql=f"({predicate})", columns=frozenset(target_columns), keep=keep) + + raise DaxTranslationError("TREATAS requires a table expression as first argument") + + def _treatas_source_width(self, source_expr: Any, source_sql: str) -> int | None: + width = _query_output_width(source_sql) + if width is not None: + return width + + return self._treatas_source_expr_width(source_expr) + + def _treatas_source_expr_width(self, source_expr: Any) -> int | None: + source_expr = self._unwrap(source_expr) + + source_table = self._table_name_from_expr(source_expr) + if source_table is not None: + column_map = self.column_sql_by_table.get(source_table, {}) + if column_map: + return len(column_map) + return None + + if not isinstance(source_expr, self.dax.FunctionCall): + return None + + name = source_expr.name.lower() + if ( + name in ("keepfilters", "nonvisual", "filter", "calculatetable", "distinct", "renamecolumns") + and source_expr.args + ): + return self._treatas_source_expr_width(source_expr.args[0]) + if name == "keepcolumns" and len(source_expr.args) >= 2: + keep_names: set[str] = set() + for raw_arg in source_expr.args[1:]: + keep_name = self._table_column_arg_name(raw_arg, function_name="KEEPCOLUMNS").lower() + keep_names.add(keep_name) + return len(keep_names) + if name == "removecolumns" and len(source_expr.args) >= 2: + source_names = self._treatas_source_expr_column_names(source_expr.args[0]) + if source_names is not None: + removed_names: set[str] = set() + for raw_arg in source_expr.args[1:]: + removed_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS").lower() + removed_names.add(removed_name) + return len(source_names - removed_names) + source_width = self._treatas_source_expr_width(source_expr.args[0]) + if source_width is None: + return None + removed_count = 0 + seen_removed: set[str] = set() + for raw_arg in source_expr.args[1:]: + removed_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS").lower() + if removed_name in seen_removed: + continue + seen_removed.add(removed_name) + removed_count += 1 + return max(source_width - removed_count, 0) + if name in ("values", "filters") and source_expr.args: + target = self._unwrap(source_expr.args[0]) + if isinstance(target, (self.dax.TableColumnRef, self.dax.HierarchyRef, self.dax.BracketRef)): + return 1 + return self._treatas_source_expr_width(target) + if ( + name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept") + and source_expr.args + ): + target = self._unwrap(source_expr.args[0]) + if isinstance(target, (self.dax.TableColumnRef, self.dax.HierarchyRef, self.dax.BracketRef)): + return 1 + return self._treatas_source_expr_width(target) + if name == "union" and len(source_expr.args) >= 2: + widths = [self._treatas_source_expr_width(arg) for arg in source_expr.args] + known_widths = [width for width in widths if width is not None] + if not known_widths: + return None + first = known_widths[0] + if any(width != first for width in known_widths): + return None + return first + if name in ("intersect", "except") and len(source_expr.args) >= 2: + left_width = self._treatas_source_expr_width(source_expr.args[0]) + right_width = self._treatas_source_expr_width(source_expr.args[1]) + if left_width is None and right_width is None: + return None + if left_width is not None and right_width is not None and left_width != right_width: + return None + return left_width if left_width is not None else right_width + if name == "crossjoin" and len(source_expr.args) >= 2: + widths = [self._treatas_source_expr_width(arg) for arg in source_expr.args] + if any(width is None for width in widths): + return None + return sum(widths) + if name in ("naturalinnerjoin", "naturalleftouterjoin") and len(source_expr.args) >= 2: + left_columns = self._treatas_source_expr_column_names(source_expr.args[0]) + right_columns = self._treatas_source_expr_column_names(source_expr.args[1]) + if left_columns is None or right_columns is None: + return None + return len(left_columns | right_columns) + if name in ("generate", "generateall") and len(source_expr.args) >= 2: + left_width = self._treatas_source_expr_width(source_expr.args[0]) + right_width = self._treatas_source_expr_width(source_expr.args[1]) + if left_width is None or right_width is None: + return None + return left_width + right_width + if name == "topn" and len(source_expr.args) >= 2: + return self._treatas_source_expr_width(source_expr.args[1]) + if name == "topnskip" and len(source_expr.args) >= 3: + return self._treatas_source_expr_width(source_expr.args[2]) + if name == "topnperlevel": + table_idx = self._topnperlevel_table_index(source_expr) + if table_idx is not None: + return self._treatas_source_expr_width(source_expr.args[table_idx]) + + return None + + def _treatas_source_expr_column_names(self, source_expr: Any) -> set[str] | None: + source_expr = self._unwrap(source_expr) + + source_table = self._table_name_from_expr(source_expr) + if source_table is not None: + column_map = self.column_sql_by_table.get(source_table, {}) + if column_map: + return {column.lower() for column in column_map} + return None + + if not isinstance(source_expr, self.dax.FunctionCall): + return None + + name = source_expr.name.lower() + if name in ("keepfilters", "nonvisual", "filter", "calculatetable", "distinct") and source_expr.args: + return self._treatas_source_expr_column_names(source_expr.args[0]) + if name == "renamecolumns" and source_expr.args: + source_names = self._treatas_source_expr_column_names(source_expr.args[0]) + if source_names is None: + return None + renamed = set(source_names) + rename_args = source_expr.args[1:] + for idx in range(0, len(rename_args) - 1, 2): + source_name = self._table_column_arg_name(rename_args[idx], function_name="RENAMECOLUMNS").lower() + target_name = self._table_column_arg_name(rename_args[idx + 1], function_name="RENAMECOLUMNS").lower() + if source_name in renamed: + renamed.remove(source_name) + renamed.add(target_name) + return renamed + if name == "keepcolumns" and len(source_expr.args) >= 2: + keep_names: set[str] = set() + for raw_arg in source_expr.args[1:]: + keep_name = self._table_column_arg_name(raw_arg, function_name="KEEPCOLUMNS").lower() + keep_names.add(keep_name) + return keep_names + if name == "removecolumns" and source_expr.args: + source_names = self._treatas_source_expr_column_names(source_expr.args[0]) + if source_names is None: + return None + removed_names: set[str] = set() + for raw_arg in source_expr.args[1:]: + removed_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS").lower() + removed_names.add(removed_name) + return source_names - removed_names + if name in ("values", "filters") and source_expr.args: + target = self._unwrap(source_expr.args[0]) + if isinstance(target, self.dax.TableColumnRef): + return {target.column.lower()} + if isinstance(target, self.dax.HierarchyRef): + column = target.levels[-1] if target.levels else target.column + return {column.lower()} + if isinstance(target, self.dax.BracketRef): + return {target.name.lower()} + return self._treatas_source_expr_column_names(target) + if ( + name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept") + and source_expr.args + ): + target = self._unwrap(source_expr.args[0]) + if isinstance(target, self.dax.TableColumnRef): + return {target.column.lower()} + if isinstance(target, self.dax.HierarchyRef): + column = target.levels[-1] if target.levels else target.column + return {column.lower()} + if isinstance(target, self.dax.BracketRef): + return {target.name.lower()} + return self._treatas_source_expr_column_names(target) + return None + + def _translate_datesbetween_filter(self, call: Any, keep: bool) -> FilterClause | None: + if len(call.args) < 3: + raise DaxTranslationError("DATESBETWEEN requires a date column, start date, and end date") + + date_column_expr = self._unwrap(call.args[0]) + with self._allow_cross_table_context(): + date_column = self._translate_scalar(date_column_expr) + + start_expr = self._unwrap(call.args[1]) + end_expr = self._unwrap(call.args[2]) + + predicates: list[str] = [] + columns = set(date_column.columns) + + if not self._is_blank_expr(start_expr): + with self._allow_cross_table_context(): + start_fragment = self._translate_scalar(start_expr) + predicates.append(f"{date_column.sql} >= {start_fragment.sql}") + columns.update(start_fragment.columns) + + if not self._is_blank_expr(end_expr): + with self._allow_cross_table_context(): + end_fragment = self._translate_scalar(end_expr) + predicates.append(f"{date_column.sql} <= {end_fragment.sql}") + columns.update(end_fragment.columns) + + if not predicates: + return None + + return FilterClause(sql=f"({' AND '.join(predicates)})", columns=frozenset(columns), keep=keep) + + def _translate_datesinperiod_filter(self, call: Any, keep: bool) -> FilterClause | None: + if len(call.args) < 4: + raise DaxTranslationError( + "DATESINPERIOD requires a date column, end date, number of intervals, and interval unit" + ) + + date_column_expr = self._unwrap(call.args[0]) + with self._allow_cross_table_context(): + date_column = self._translate_scalar(date_column_expr) + end_fragment = self._translate_scalar(call.args[1]) + + periods = self._number_literal_value(call.args[2]) + unit = self._identifier_literal_value(call.args[3]) + if periods is None or unit is None: + raise DaxTranslationError("DATESINPERIOD interval count and unit must be literals") + + normalized_unit = unit.lower() + if normalized_unit.endswith("s"): + normalized_unit = normalized_unit[:-1] + if normalized_unit not in ("day", "week", "month", "quarter", "year"): + raise DaxTranslationError("DATESINPERIOD interval unit must be day, week, month, quarter, or year") + + interval_value = periods + interval_unit = normalized_unit + if normalized_unit == "quarter": + interval_value = periods * 3 + interval_unit = "month" + elif normalized_unit == "week": + interval_value = periods * 7 + interval_unit = "day" + + offset_sql = f"({end_fragment.sql} + INTERVAL '{interval_value} {interval_unit}')" + if periods < 0: + predicate = f"{date_column.sql} > {offset_sql} AND {date_column.sql} <= {end_fragment.sql}" + elif periods > 0: + predicate = f"{date_column.sql} >= {end_fragment.sql} AND {date_column.sql} < {offset_sql}" + else: + predicate = f"{date_column.sql} = {end_fragment.sql}" + + columns = set(date_column.columns) + columns.update(end_fragment.columns) + return FilterClause(sql=f"({predicate})", columns=frozenset(columns), keep=keep) + + def _translate_cumulative_period_filter(self, call: Any, keep: bool) -> FilterClause | None: + if not call.args: + raise DaxTranslationError("DATESYTD/DATESMTD/DATESQTD/DATESWTD requires a date column argument") + + grain_map = { + "datesytd": "year", + "datesmtd": "month", + "datesqtd": "quarter", + "dateswtd": "week", + } + name = call.name.lower() + grain = grain_map.get(name) + if grain is None: + raise DaxTranslationError(f"Unsupported cumulative period function {call.name}") + + date_expr = self._unwrap(call.args[0]) + with self._allow_cross_table_context(): + date_fragment = self._translate_scalar(date_expr) + + anchor_sql = self._relative_period_anchor_sql(date_fragment) + start_sql = f"DATE_TRUNC('{grain}', {anchor_sql})" + predicate = f"{date_fragment.sql} >= {start_sql} AND {date_fragment.sql} <= {anchor_sql}" + return FilterClause(sql=f"({predicate})", columns=frozenset(date_fragment.columns), keep=keep) + + def _translate_relative_period_filter(self, call: Any, keep: bool) -> FilterClause | None: + if not call.args: + raise DaxTranslationError(f"{call.name} requires a date column argument") + + date_expr = self._unwrap(call.args[0]) + with self._allow_cross_table_context(): + date_fragment = self._translate_scalar(date_expr) + + name = call.name.lower() + if name == "sameperiodlastyear": + offset_unit: tuple[int, str] | None = (1, "year") + elif name in ("dateadd", "parallelperiod"): + offset_unit = self._parse_dateadd(call) + else: + offset_unit = _time_offset_for_period_function(name) + + if offset_unit is None: + raise DaxTranslationError(f"Unsupported relative period function {call.name}") + + offset, unit = offset_unit + interval_offset = offset + interval_unit = unit + if unit == "quarter": + interval_offset = offset * 3 + interval_unit = "month" + elif unit == "week": + interval_offset = offset * 7 + interval_unit = "day" + + anchor_sql = self._relative_period_anchor_sql(date_fragment) + if offset > 0: + lower_sql = f"({anchor_sql} + INTERVAL '-{abs(interval_offset)} {interval_unit}')" + predicate = f"{date_fragment.sql} > {lower_sql} AND {date_fragment.sql} <= {anchor_sql}" + elif offset < 0: + upper_sql = f"({anchor_sql} + INTERVAL '{abs(interval_offset)} {interval_unit}')" + predicate = f"{date_fragment.sql} >= {anchor_sql} AND {date_fragment.sql} < {upper_sql}" + else: + predicate = f"{date_fragment.sql} = {anchor_sql}" + + return FilterClause(sql=f"({predicate})", columns=frozenset(date_fragment.columns), keep=keep) + + def _relative_period_anchor_sql(self, date_fragment: _SqlFragment) -> str: + if len(date_fragment.columns) != 1: + raise DaxTranslationError("Relative period filters require a single date column reference") + column_ref = next(iter(date_fragment.columns)) + table = column_ref.table + column = column_ref.column + if table is None or column is None: + raise DaxTranslationError("Relative period filters require a qualified date column reference") + with self._allow_cross_table_context(): + column_sql = self._column_sql(table, column) + table_sql = self._table_sql(table) + return f"(SELECT MAX({column_sql}) FROM {table_sql})" + + def _translate_filter_removal(self, expr: Any) -> FilterRemoval | None: + with self._allow_cross_table_context(): + expr = self._unwrap(expr) + table_name = self._table_name_from_expr(expr) + if table_name is not None: + self._ensure_table_context(table_name) + return FilterRemoval(table=table_name) + if isinstance(expr, self.dax.TableColumnRef): + self._ensure_table_context(expr.table.name) + return FilterRemoval(table=expr.table.name, column=expr.column) + if isinstance(expr, self.dax.HierarchyRef): + column = expr.levels[-1] if expr.levels else expr.column + self._ensure_table_context(expr.table.name) + return FilterRemoval(table=expr.table.name, column=column) + if isinstance(expr, self.dax.BracketRef): + return FilterRemoval(column=expr.name) + return None + + def _translate_allexcept(self, call: Any) -> FilterRetention | None: + if not call.args: + raise DaxTranslationError("ALLEXCEPT requires at least a table argument") + + with self._allow_cross_table_context(): + table_expr = self._unwrap(call.args[0]) + table_name = self._table_name_from_expr(table_expr) + if table_name is None: + raise DaxTranslationError("ALLEXCEPT first argument must be a table reference") + self._ensure_table_context(table_name) + + kept_columns: set[str] = set() + for arg in call.args[1:]: + expr = self._unwrap(arg) + if isinstance(expr, self.dax.TableColumnRef): + if expr.table.name.lower() != table_name.lower(): + raise DaxTranslationError("ALLEXCEPT columns must belong to the same table") + kept_columns.add(expr.column.lower()) + continue + if isinstance(expr, self.dax.HierarchyRef): + if expr.table.name.lower() != table_name.lower(): + raise DaxTranslationError("ALLEXCEPT columns must belong to the same table") + column = expr.levels[-1] if expr.levels else expr.column + kept_columns.add(column.lower()) + continue + if isinstance(expr, self.dax.BracketRef): + kept_columns.add(expr.name.lower()) + continue + if isinstance(expr, self.dax.Identifier): + kept_columns.add(expr.name.lower()) + continue + raise DaxTranslationError("ALLEXCEPT only supports column references after the table argument") + + return FilterRetention(table=table_name, columns=frozenset(kept_columns)) + + def _translate_userelationship(self, call: Any) -> RelationshipOverride | None: + if len(call.args) < 2: + raise DaxTranslationError("USERELATIONSHIP requires two column references") + left = self._unwrap(call.args[0]) + right = self._unwrap(call.args[1]) + if not isinstance(left, self.dax.TableColumnRef) or not isinstance(right, self.dax.TableColumnRef): + raise DaxTranslationError("USERELATIONSHIP expects Table[Column] arguments") + + self._required_models.update({left.table.name, right.table.name}) + return RelationshipOverride( + from_model=left.table.name, + from_column=left.column, + to_model=right.table.name, + to_column=right.column, + join_type=None, + direction=None, + ) + + def _translate_crossfilter(self, call: Any) -> RelationshipOverride | None: + if len(call.args) < 3: + raise DaxTranslationError("CROSSFILTER requires two columns and a direction") + left = self._unwrap(call.args[0]) + right = self._unwrap(call.args[1]) + direction_expr = call.args[2] + + if not isinstance(left, self.dax.TableColumnRef) or not isinstance(right, self.dax.TableColumnRef): + raise DaxTranslationError("CROSSFILTER expects Table[Column] arguments") + + direction = None + if isinstance(direction_expr, self.dax.String): + direction = direction_expr.value + elif isinstance(direction_expr, self.dax.Identifier): + direction = direction_expr.name + + if not direction: + raise DaxTranslationError("CROSSFILTER direction must be a string or identifier") + + normalized = direction.replace(" ", "").upper() + allowed: dict[str, tuple[str | None, str]] = { + "BOTH": ("inner", "Both"), + "NONE": ("left", "None"), + "ONEWAY": (None, "OneWay"), + "ONEWAY_LEFTFILTERSRIGHT": (None, "OneWay_LeftFiltersRight"), + "ONEWAY_RIGHTFILTERSLEFT": (None, "OneWay_RightFiltersLeft"), + } + if normalized not in allowed: + raise DaxTranslationError( + "CROSSFILTER direction must be one of BOTH, NONE, ONEWAY, " + "ONEWAY_LEFTFILTERSRIGHT, ONEWAY_RIGHTFILTERSLEFT" + ) + join_type, canonical_direction = allowed[normalized] + + self._required_models.update({left.table.name, right.table.name}) + return RelationshipOverride( + from_model=left.table.name, + from_column=left.column, + to_model=right.table.name, + to_column=right.column, + join_type=join_type, + direction=canonical_direction, + ) + + def _apply_filter_retentions( + self, filters: list[FilterClause], retentions: list[FilterRetention], remove_all: bool + ) -> list[FilterClause]: + if remove_all: + return [] + if not retentions: + return filters + + remaining = filters + for retention in retentions: + retained: list[FilterClause] = [] + for clause in remaining: + if self._is_removed_by_retention(clause, retention): + continue + retained.append(clause) + remaining = retained + return remaining + + @staticmethod + def _is_removed_by_retention(clause: FilterClause, retention: FilterRetention) -> bool: + matching_columns = [col for col in clause.columns if col.table and col.table.lower() == retention.table.lower()] + if not matching_columns: + return False + if not retention.columns: + return True + for col in matching_columns: + if col.column.lower() not in retention.columns: + return True + return False + + @staticmethod + def _apply_non_keep_clear( + filters: list[FilterClause], remove_all: bool, clear_non_keep: bool + ) -> list[FilterClause]: + if remove_all: + return [] + if not clear_non_keep: + return filters + return [clause for clause in filters if clause.keep] + + def _apply_filter_removals( + self, filters: list[FilterClause], removals: list[FilterRemoval], remove_all: bool + ) -> list[str]: + if remove_all: + return [] + + remaining = [] + for clause in filters: + if self._is_removed(clause, removals): + continue + remaining.append(clause.sql) + return remaining + + @staticmethod + def _is_removed(clause: FilterClause, removals: list[FilterRemoval]) -> bool: + for removal in removals: + for col in clause.columns: + if removal.table and col.table and removal.table.lower() != col.table.lower(): + continue + if removal.column and removal.column.lower() != col.column.lower(): + continue + return True + return False + + def _filter_clause_from_sql(self, sql: str, keep: bool = False) -> FilterClause: + return FilterClause(sql=sql, columns=frozenset(self._columns_from_sql(sql)), keep=keep) + + def _columns_from_sql(self, sql: str) -> set[ColumnRef]: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + except Exception: + return set() + + columns: set[ColumnRef] = set() + for column in parsed.find_all(exp.Column): + table = column.table + if table: + columns.add(ColumnRef(table=table, column=column.name)) + elif self.model_name: + columns.add(ColumnRef(table=self.model_name, column=column.name)) + elif self._base_table: + columns.add(ColumnRef(table=self._base_table, column=column.name)) + return columns + + def _filters_from_table(self, table_expr: Any) -> tuple[list[str], list[RelationshipOverride]]: + table_expr = self._unwrap(table_expr) + table_name = self._table_name_from_expr(table_expr) + if table_name is not None: + self._ensure_table_context(table_name) + return [], [] + + if isinstance(table_expr, self.dax.FunctionCall) and table_expr.name.lower() == "filter": + base_filters, base_overrides = self._filters_from_table(table_expr.args[0]) if table_expr.args else ([], []) + predicate = table_expr.args[1] if len(table_expr.args) > 1 else None + if predicate is None: + return base_filters, base_overrides + clause = self._translate_predicate(predicate, keep=False) + return [*base_filters, clause.sql], base_overrides + + if isinstance(table_expr, self.dax.FunctionCall): + name = table_expr.name.lower() + if name in ("keepfilters", "nonvisual"): + if not table_expr.args: + return [], [] + return self._filters_from_table(table_expr.args[0]) + if name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept"): + return [], [] + if name == "datesbetween": + clause = self._translate_datesbetween_filter(table_expr, keep=False) + if clause is None: + return [], [] + return [clause.sql], [] + if name == "datesinperiod": + clause = self._translate_datesinperiod_filter(table_expr, keep=False) + if clause is None: + return [], [] + return [clause.sql], [] + if name in ("datesytd", "datesmtd", "datesqtd", "dateswtd"): + clause = self._translate_cumulative_period_filter(table_expr, keep=False) + if clause is None: + return [], [] + return [clause.sql], [] + if name in ( + "sameperiodlastyear", + "dateadd", + "parallelperiod", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + ): + clause = self._translate_relative_period_filter(table_expr, keep=False) + if clause is None: + return [], [] + return [clause.sql], [] + if name == "calculatetable": + if not table_expr.args: + return [], [] + base_filters, base_overrides = self._filters_from_table(table_expr.args[0]) + ( + new_filters, + removals, + retentions, + remove_all, + clear_non_keep, + new_overrides, + ) = self._translate_filter_args(table_expr.args[1:]) + inherited = [self._filter_clause_from_sql(sql, keep=False) for sql in base_filters] + combined = self._merge_filter_clauses(inherited, new_filters) + combined = self._apply_non_keep_clear(combined, remove_all, clear_non_keep) + retained = self._apply_filter_retentions(combined, retentions, remove_all) + return self._apply_filter_removals(retained, removals, remove_all), [*base_overrides, *new_overrides] + if name in ( + "selectcolumns", + "addcolumns", + "summarize", + "keepcolumns", + "removecolumns", + "renamecolumns", + "substitutewithindex", + ): + self._translate_table(table_expr) + if table_expr.args: + return self._filters_from_table(table_expr.args[0]) + return [], [] + if name == "topn": + self._translate_table(table_expr) + if len(table_expr.args) > 1: + return self._filters_from_table(table_expr.args[1]) + return [], [] + if name in ("calendar", "generateseries"): + self._translate_table(table_expr) + return [], [] + if name == "union": + self._translate_table(table_expr) + filters: list[str] = [] + overrides: list[RelationshipOverride] = [] + for arg in table_expr.args: + nested_filters, nested_overrides = self._filters_from_table(arg) + filters.extend(nested_filters) + overrides.extend(nested_overrides) + return filters, overrides + if name in ("crossjoin", "naturalinnerjoin", "naturalleftouterjoin"): + self._translate_table(table_expr) + filters: list[str] = [] + overrides: list[RelationshipOverride] = [] + for arg in table_expr.args: + nested_filters, nested_overrides = self._filters_from_table(arg) + filters.extend(nested_filters) + overrides.extend(nested_overrides) + return filters, overrides + if name == "groupby": + self._translate_table(table_expr) + if table_expr.args: + return self._filters_from_table(table_expr.args[0]) + return [], [] + if name == "datatable": + self._translate_table(table_expr) + return [], [] + if name == "relatedtable": + self._translate_table(table_expr) + return [], [] + if name in ("generate", "generateall"): + self._translate_table(table_expr) + filters: list[str] = [] + overrides: list[RelationshipOverride] = [] + for arg in table_expr.args[:2]: + nested_filters, nested_overrides = self._filters_from_table(arg) + filters.extend(nested_filters) + overrides.extend(nested_overrides) + return filters, overrides + if name == "topnskip": + self._translate_table(table_expr) + if len(table_expr.args) > 2: + return self._filters_from_table(table_expr.args[2]) + return [], [] + if name == "topnperlevel": + self._translate_table(table_expr) + table_idx = self._topnperlevel_table_index(table_expr) + if table_idx is None: + return [], [] + return self._filters_from_table(table_expr.args[table_idx]) + if name == "addmissingitems": + self._translate_table(table_expr) + table_arg = self._addmissingitems_table_arg(table_expr) + if table_arg is None: + return [], [] + return self._filters_from_table(table_arg) + if name == "currentgroup": + return [], [] + if name in ("intersect", "except"): + self._translate_table(table_expr) + return [], [] + if name == "summarizecolumns": + self._translate_table(table_expr) + filters: list[str] = [] + overrides: list[RelationshipOverride] = [] + for arg in table_expr.args: + arg = self._unwrap(arg) + if isinstance(arg, self.dax.FunctionCall) and arg.name.lower() in ( + "filter", + "keepfilters", + "nonvisual", + "treatas", + "datesbetween", + "datesinperiod", + "datesytd", + "datesmtd", + "datesqtd", + "dateswtd", + "sameperiodlastyear", + "dateadd", + "parallelperiod", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + ): + candidate = arg + if arg.name.lower() == "keepfilters": + inner = arg.args[0] if arg.args else None + if inner is None: + continue + candidate = self._unwrap(inner) + elif arg.name.lower() == "nonvisual": + inner = arg.args[0] if arg.args else None + if inner is None: + continue + candidate = self._unwrap(inner) + nested_filters, nested_overrides = self._translate_filter_candidate(candidate, keep=False) + filters.extend(clause.sql for clause in nested_filters) + overrides.extend(nested_overrides) + return filters, overrides + if name in ("values", "filters", "distinct"): + self._translate_table(table_expr) + if table_expr.args: + target = self._unwrap(table_expr.args[0]) + if isinstance( + target, + (self.dax.Identifier, self.dax.TableRef, self.dax.FunctionCall, self.dax.TableConstructor), + ): + return self._filters_from_table(target) + return [], [] + return [], [] + + def _translate_predicate(self, expr: Any, keep: bool = False) -> FilterClause: + with self._allow_cross_table_context(): + fragment = self._translate_scalar(expr) + return FilterClause(sql=fragment.sql, columns=frozenset(fragment.columns), keep=keep) + + def _predicate_uses_unknown_columns(self, fragment: _SqlFragment) -> bool: + for column in fragment.columns: + table = column.table + if table is None: + return True + resolved_table = self._resolve_known_table_name(table) + if resolved_table is None: + return True + if not self._table_has_known_column(resolved_table, column.column): + return True + return False + + def _predicate_needs_derived_alias_fallback(self, predicate_expr: Any, predicate_clause: FilterClause) -> bool: + fragment = _SqlFragment(predicate_clause.sql, predicate_clause.columns) + if self._predicate_uses_unknown_columns(fragment): + return True + if not predicate_clause.columns and self._predicate_has_unqualified_identifier(predicate_expr): + return True + return False + + def _translate_alias_backed_filter_predicate( + self, + source_expr: Any, + predicate_expr: Any, + *, + keep: bool, + ) -> FilterClause | None: + alias_env = self._derived_filter_alias_env(source_expr) + if not alias_env: + return None + alias_keys = set(alias_env) + if not self._predicate_references_alias(predicate_expr, alias_keys): + return None + prior_env = dict(self._env) + self._env.update(alias_env) + try: + clause = self._translate_predicate(predicate_expr, keep=keep) + finally: + self._env = prior_env + if clause.columns: + return clause + return None + + def _predicate_has_unqualified_identifier(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if isinstance(expr, (self.dax.Identifier, self.dax.BracketRef)): + return True + if isinstance(expr, (self.dax.TableColumnRef, self.dax.HierarchyRef)): + return False + if isinstance(expr, self.dax.Unary): + return self._predicate_has_unqualified_identifier(expr.expr) + if isinstance(expr, self.dax.Binary): + return self._predicate_has_unqualified_identifier(expr.left) or self._predicate_has_unqualified_identifier( + expr.right + ) + if isinstance(expr, self.dax.VarBlock): + for decl in expr.decls: + if self._predicate_has_unqualified_identifier(decl.expr): + return True + return self._predicate_has_unqualified_identifier(expr.body) + if isinstance(expr, self.dax.FunctionCall): + for arg in expr.args: + if self._predicate_has_unqualified_identifier(arg): + return True + return False + if isinstance(expr, self.dax.Paren): + return self._predicate_has_unqualified_identifier(expr.expr) + return False + + def _predicate_references_alias(self, expr: Any, alias_keys: set[str]) -> bool: + expr = self._unwrap(expr) + if isinstance(expr, (self.dax.Identifier, self.dax.BracketRef)): + return expr.name.lower() in alias_keys + if isinstance(expr, (self.dax.TableColumnRef, self.dax.HierarchyRef)): + return False + if isinstance(expr, self.dax.Unary): + return self._predicate_references_alias(expr.expr, alias_keys) + if isinstance(expr, self.dax.Binary): + return self._predicate_references_alias(expr.left, alias_keys) or self._predicate_references_alias( + expr.right, alias_keys + ) + if isinstance(expr, self.dax.VarBlock): + for decl in expr.decls: + if self._predicate_references_alias(decl.expr, alias_keys): + return True + return self._predicate_references_alias(expr.body, alias_keys) + if isinstance(expr, self.dax.FunctionCall): + for arg in expr.args: + if self._predicate_references_alias(arg, alias_keys): + return True + return False + if isinstance(expr, self.dax.Paren): + return self._predicate_references_alias(expr.expr, alias_keys) + return False + + def _derived_filter_alias_env(self, expr: Any) -> dict[str, _SqlFragment]: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.FunctionCall): + name = expr.name.lower() + if name in ("keepfilters", "nonvisual", "filter", "calculatetable", "distinct") and expr.args: + return self._derived_filter_alias_env(expr.args[0]) + if name == "topn" and len(expr.args) > 1: + return self._derived_filter_alias_env(expr.args[1]) + if name == "topnskip" and len(expr.args) > 2: + return self._derived_filter_alias_env(expr.args[2]) + if name == "topnperlevel": + table_idx = self._topnperlevel_table_index(expr) + if table_idx is not None: + return self._derived_filter_alias_env(expr.args[table_idx]) + return {} + if name == "selectcolumns": + return self._selectcolumns_alias_env(expr) + if name == "addcolumns": + return self._addcolumns_alias_env(expr) + if name == "renamecolumns": + return self._renamecolumns_alias_env(expr) + if name == "row": + return self._row_alias_env(expr) + return {} + + def _selectcolumns_alias_env(self, call: Any) -> dict[str, _SqlFragment]: + if not call.args: + return {} + pairs = call.args[1:] + if len(pairs) % 2 != 0: + return {} + alias_env: dict[str, _SqlFragment] = {} + base_expr = self._unwrap(call.args[0]) + base_table_name = self._table_name_from_expr(base_expr) + if base_table_name is not None: + with self._allow_cross_table_context(): + self._ensure_table_context(base_table_name) + for i in range(0, len(pairs), 2): + alias = self._string_literal_value(pairs[i]) + if alias is None: + return {} + alias_env[alias.lower()] = self._translate_projection_scalar(pairs[i + 1]) + return alias_env + _from_sql, wrapped = self._table_source_from_expr(call.args[0]) + for i in range(0, len(pairs), 2): + alias = self._string_literal_value(pairs[i]) + if alias is None: + return {} + fragment = ( + self._translate_projection_scalar(pairs[i + 1]) if wrapped else self._translate_scalar(pairs[i + 1]) + ) + alias_env[alias.lower()] = fragment + return alias_env + + def _addcolumns_alias_env(self, call: Any) -> dict[str, _SqlFragment]: + if not call.args: + return {} + pairs = call.args[1:] + if len(pairs) % 2 != 0: + return {} + alias_env = self._derived_filter_alias_env(call.args[0]) + base_expr = self._unwrap(call.args[0]) + base_table_name = self._table_name_from_expr(base_expr) + wrapped = False + if base_table_name is not None: + with self._allow_cross_table_context(): + self._ensure_table_context(base_table_name) + else: + _from_sql, wrapped = self._table_source_from_expr(call.args[0]) + for i in range(0, len(pairs), 2): + alias = self._string_literal_value(pairs[i]) + if alias is None: + return {} + fragment = ( + self._translate_projection_scalar(pairs[i + 1]) + if wrapped or base_table_name is not None + else self._translate_scalar(pairs[i + 1]) + ) + alias_env[alias.lower()] = fragment + return alias_env + + def _renamecolumns_alias_env(self, call: Any) -> dict[str, _SqlFragment]: + if len(call.args) < 3: + return {} + alias_env = self._derived_filter_alias_env(call.args[0]) + rename_args = call.args[1:] + if len(rename_args) % 2 != 0: + return {} + for i in range(0, len(rename_args), 2): + source_name = self._table_column_arg_name(rename_args[i], function_name="RENAMECOLUMNS").lower() + target_name = self._table_column_arg_name(rename_args[i + 1], function_name="RENAMECOLUMNS").lower() + fragment = alias_env.pop(source_name, None) + if fragment is None: + try: + fragment = self._translate_scalar(rename_args[i]) + except DaxTranslationError: + return {} + alias_env[target_name] = fragment + return alias_env + + def _row_alias_env(self, call: Any) -> dict[str, _SqlFragment]: + if len(call.args) < 2 or len(call.args) % 2 != 0: + return {} + alias_env: dict[str, _SqlFragment] = {} + for i in range(0, len(call.args), 2): + alias = self._string_literal_value(call.args[i]) + if alias is None: + return {} + try: + fragment = self._translate_projection_scalar(call.args[i + 1]) + except DaxTranslationError: + return {} + alias_env[alias.lower()] = fragment + return alias_env + + @staticmethod + def _is_opaque_filter_predicate(predicate_sql: str) -> bool: + return "AS __filter_table" in predicate_sql + + def _merge_filter_clauses(self, inherited: list[FilterClause], incoming: list[FilterClause]) -> list[FilterClause]: + merged = list(inherited) + for clause in incoming: + if clause.keep or not clause.columns: + merged.append(clause) + continue + + retained: list[FilterClause] = [] + for existing in merged: + if self._filters_overlap(existing, clause): + continue + retained.append(existing) + retained.append(clause) + merged = retained + return merged + + @staticmethod + def _filters_overlap(left: FilterClause, right: FilterClause) -> bool: + for left_col in left.columns: + for right_col in right.columns: + if _columns_match(left_col, right_col): + return True + return False + + def _translate_table(self, expr: Any) -> str: + expr = self._unwrap(expr) + table_name = self._table_name_from_expr(expr) + if table_name is not None: + self._ensure_table_context(table_name) + table_sql = self._table_sql(table_name) + return f"SELECT * FROM {table_sql}" + if isinstance(expr, self.dax.TableConstructor): + return self._translate_table_constructor(expr) + if isinstance(expr, self.dax.FunctionCall): + name = expr.name.lower() + if name in ("keepfilters", "nonvisual"): + return self._translate_table_wrapper(expr) + if name == "filter": + return self._translate_filter_table(expr) + if name == "row": + return self._translate_row_table(expr) + if name == "selectcolumns": + return self._translate_selectcolumns(expr) + if name == "addcolumns": + return self._translate_addcolumns(expr) + if name == "summarizecolumns": + return self._translate_summarizecolumns(expr) + if name == "summarize": + return self._translate_summarize(expr) + if name == "groupby": + return self._translate_groupby(expr) + if name == "topn": + return self._translate_topn(expr) + if name == "topnperlevel": + return self._translate_topnperlevel(expr) + if name == "union": + return self._translate_union_table(expr) + if name == "crossjoin": + return self._translate_crossjoin_table(expr) + if name in ("generate", "generateall"): + return self._translate_generate_table(expr) + if name == "naturalinnerjoin": + return self._translate_natural_inner_join_table(expr) + if name == "naturalleftouterjoin": + return self._translate_natural_left_outer_join_table(expr) + if name == "intersect": + return self._translate_intersect_table(expr) + if name == "except": + return self._translate_except_table(expr) + if name == "topnskip": + return self._translate_topnskip(expr) + if name == "calendar": + return self._translate_calendar_table(expr) + if name == "generateseries": + return self._translate_generateseries_table(expr) + if name == "datatable": + return self._translate_datatable_table(expr) + if name == "relatedtable": + return self._translate_relatedtable_table(expr) + if name == "calculatetable": + return self._translate_calculatetable(expr) + if name == "addmissingitems": + return self._translate_addmissingitems_table(expr) + if name == "treatas": + return self._translate_treatas_table(expr) + if name == "datesbetween": + return self._translate_datesbetween_table(expr) + if name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept"): + return self._translate_all_like_table(expr) + if name in ( + "firstdate", + "lastdate", + "startofmonth", + "startofquarter", + "startofyear", + "endofmonth", + "endofquarter", + "endofyear", + ): + return self._translate_date_boundary_table(expr) + if name == "values": + return self._translate_values_table(expr) + if name == "filters": + return self._translate_filters_table(expr) + if name == "distinct": + return self._translate_distinct_table(expr) + if name == "renamecolumns": + return self._translate_renamecolumns_table(expr) + if name == "keepcolumns": + return self._translate_keepcolumns_table(expr) + if name == "removecolumns": + return self._translate_removecolumns_table(expr) + if name == "substitutewithindex": + return self._translate_substitutewithindex_table(expr) + if name in ("selectedmeasure", "selectedmeasurename", "selectedmeasureformatstring", "isselectedmeasure"): + raise DaxTranslationError(f"{expr.name} is only supported in calculation group expressions") + if name == "detailrows": + raise DaxTranslationError("DETAILROWS is only supported in model detail rows expressions") + if isinstance(expr, self.dax.FunctionCall): + raise DaxTranslationError(f"Unsupported table function '{expr.name}'") + if isinstance(expr, self.dax.Identifier): + raise DaxTranslationError(f"Unknown table identifier '{expr.name}'") + raise DaxTranslationError(f"Unsupported table expression type '{type(expr).__name__}'") + + def _translate_table_wrapper(self, call: Any) -> str: + if len(call.args) != 1: + raise DaxTranslationError(f"{call.name} requires exactly one table-expression argument") + inner = self._unwrap(call.args[0]) + if self._table_name_from_expr(inner) is not None: + return self._translate_table(inner) + if isinstance(inner, (self.dax.FunctionCall, self.dax.TableConstructor)): + return self._translate_table(inner) + raise DaxTranslationError(f"{call.name} requires a table-expression argument") + + def _translate_datesbetween_table(self, call: Any) -> str: + if len(call.args) < 3: + raise DaxTranslationError("DATESBETWEEN requires a date column, start date, and end date") + + date_column_expr = self._unwrap(call.args[0]) + table_name: str | None = None + if isinstance(date_column_expr, self.dax.TableColumnRef): + table_name = date_column_expr.table.name + elif isinstance(date_column_expr, self.dax.HierarchyRef): + table_name = date_column_expr.table.name + elif self.model_name: + table_name = self.model_name + elif self._base_table: + table_name = self._base_table + + if table_name is None: + raise DaxTranslationError("DATESBETWEEN requires a table-qualified date column") + + with self._allow_cross_table_context(): + self._ensure_table_context(table_name) + + clause = self._translate_datesbetween_filter(call, keep=False) + tables_in_order = [table_name] + seen_tables = {table_name.lower()} + if clause is not None: + self._append_tables(tables_in_order, seen_tables, clause.columns) + from_clause = self._build_from_clause_for_tables(tables_in_order) + select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(table_name)}.*" + if clause is None: + return f"SELECT {select_sql} FROM {from_clause}" + return f"SELECT {select_sql} FROM {from_clause} WHERE {clause.sql}" + + def _translate_treatas_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("TREATAS requires a table expression and at least one target column") + + target_exprs = [self._unwrap(arg) for arg in call.args[1:]] + with self._allow_cross_table_context(): + target_fragments = [self._translate_scalar(target) for target in target_exprs] + + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + for fragment in target_fragments: + self._append_tables(tables_in_order, seen_tables, fragment.columns) + if not tables_in_order: + raise DaxTranslationError("TREATAS target arguments must reference table columns") + + from_clause = self._build_from_clause_for_tables(tables_in_order) + clause = self._translate_treatas_filter(call, keep=False) + + select_parts: list[str] = [] + for idx, fragment in enumerate(target_fragments): + alias = _column_name_from_expr_sql(fragment.sql) or f"value{idx + 1}" + select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + + return f"SELECT DISTINCT {', '.join(select_parts)} FROM {from_clause} WHERE {clause.sql}" + + def _translate_table_constructor(self, constructor: Any) -> str: + if not constructor.rows: + return "SELECT NULL AS value1 WHERE FALSE" + + width = len(constructor.rows[0]) + if width == 0: + return "SELECT NULL AS value1 WHERE FALSE" + + row_selects: list[str] = [] + for row in constructor.rows: + if len(row) != width: + raise DaxTranslationError("Table constructor rows must have the same number of values") + fragments: list[_SqlFragment] = [] + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + all_values_self_contained = True + for value in row: + try: + fragment = self._translate_scalar(value) + except DaxTranslationError as exc: + if str(exc) != "DAX table expressions must reference a single base table": + raise + with self._allow_cross_table_context(): + fragment = self._translate_scalar(value) + fragments.append(fragment) + self._append_tables(tables_in_order, seen_tables, fragment.columns) + fragment_sql_upper = fragment.sql.strip().upper() + if not (fragment_sql_upper.startswith("SELECT ") or fragment_sql_upper.startswith("(SELECT ")): + all_values_self_contained = False + + cols = [f"{fragment.sql} AS value{idx + 1}" for idx, fragment in enumerate(fragments)] + row_sql = "SELECT " + ", ".join(cols) + if tables_in_order and not all_values_self_contained: + row_sql = f"{row_sql} FROM {self._build_from_clause_for_tables(tables_in_order)}" + row_selects.append(row_sql) + + return " UNION ALL ".join(row_selects) + + def _translate_filter_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("FILTER requires table and predicate") + base_expr = self._unwrap(call.args[0]) + base_table_name = self._table_name_from_expr(base_expr) + if base_table_name is not None: + self._ensure_table_context(base_table_name) + with self._allow_cross_table_context(): + with self._prefer_unqualified_base_table_context(): + predicate = self._translate_scalar(call.args[1]) + + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + self._append_tables(tables_in_order, seen_tables, predicate.columns) + from_clause = self._build_from_clause_for_tables(tables_in_order) + select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" + return f"SELECT {select_sql} FROM {from_clause} WHERE {predicate.sql}" + + from_sql, wrapped = self._table_source_from_expr(call.args[0]) + if wrapped: + with self._prefer_unqualified_base_table_context(): + predicate = self._translate_projection_scalar(call.args[1]) + else: + predicate = self._translate_scalar(call.args[1]) + select_sql = "*" + if wrapped and self._base_table: + tables_in_order = [self._base_table] + seen_tables = {self._base_table.lower()} + self._append_tables(tables_in_order, seen_tables, predicate.columns) + if len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) + select_sql = "t.*" + return f"SELECT {select_sql} FROM {from_sql} WHERE {predicate.sql}" + + def _translate_row_table(self, call: Any) -> str: + if len(call.args) < 2 or len(call.args) % 2 != 0: + raise DaxTranslationError("ROW requires name/expression pairs") + + prior_base_table = self._base_table + select_parts: list[str] = [] + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + all_values_self_contained = True + for idx in range(0, len(call.args), 2): + alias = self._string_literal_value(call.args[idx]) + if alias is None: + raise DaxTranslationError("ROW name must be a string") + value_expr = call.args[idx + 1] + try: + fragment = self._translate_scalar(value_expr) + except DaxTranslationError as exc: + if str(exc) != "DAX table expressions must reference a single base table": + raise + prefer_unqualified_base = self.model_name is None and not self._allow_cross_table + qualifier_ctx = ( + self._prefer_unqualified_base_table_context() if prefer_unqualified_base else nullcontext() + ) + with self._allow_cross_table_context(): + with qualifier_ctx: + fragment = self._translate_scalar(value_expr) + select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + self._append_tables(tables_in_order, seen_tables, fragment.columns) + fragment_sql_upper = fragment.sql.strip().upper() + if not (fragment_sql_upper.startswith("SELECT ") or fragment_sql_upper.startswith("(SELECT ")): + all_values_self_contained = False + + select_sql = ", ".join(select_parts) + if not tables_in_order: + return f"SELECT {select_sql}" + if all_values_self_contained: + return f"SELECT {select_sql}" + if prior_base_table is not None and all(table.lower() == prior_base_table.lower() for table in tables_in_order): + return f"SELECT {select_sql}" + from_clause = self._build_from_clause_for_tables(tables_in_order) + return f"SELECT {select_sql} FROM {from_clause}" + + def _translate_union_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("UNION requires at least two table arguments") + + parts: list[str] = [] + for idx, arg in enumerate(call.args): + base_sql = self._translate_table_with_isolated_base_context(arg, preserve_result_base=idx == 0) + parts.append(f"SELECT * FROM ({base_sql}) AS t{idx}") + return " UNION ALL ".join(parts) + + def _translate_crossjoin_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("CROSSJOIN requires at least two table arguments") + + from_parts: list[str] = [] + for idx, arg in enumerate(call.args): + base_sql = self._translate_table_with_isolated_base_context(arg, preserve_result_base=idx == 0) + from_parts.append(f"({base_sql}) AS t{idx}") + return f"SELECT * FROM {' CROSS JOIN '.join(from_parts)}" + + def _translate_natural_inner_join_table(self, call: Any) -> str: + if len(call.args) != 2: + raise DaxTranslationError("NATURALINNERJOIN requires exactly two table arguments") + + left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) + right_sql = self._translate_table_with_isolated_base_context(call.args[1]) + return f"SELECT * FROM ({left_sql}) AS t0 NATURAL INNER JOIN ({right_sql}) AS t1" + + def _translate_natural_left_outer_join_table(self, call: Any) -> str: + if len(call.args) != 2: + raise DaxTranslationError("NATURALLEFTOUTERJOIN requires exactly two table arguments") + + left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) + right_sql = self._translate_table_with_isolated_base_context(call.args[1]) + return f"SELECT * FROM ({left_sql}) AS t0 NATURAL LEFT JOIN ({right_sql}) AS t1" + + def _translate_intersect_table(self, call: Any) -> str: + if len(call.args) != 2: + raise DaxTranslationError("INTERSECT requires exactly two table arguments") + + left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) + right_sql = self._translate_table_with_isolated_base_context(call.args[1]) + return f"SELECT * FROM ({left_sql}) AS t0 INTERSECT ALL SELECT * FROM ({right_sql}) AS t1" + + def _translate_except_table(self, call: Any) -> str: + if len(call.args) != 2: + raise DaxTranslationError("EXCEPT requires exactly two table arguments") + + left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) + right_sql = self._translate_table_with_isolated_base_context(call.args[1]) + return f"SELECT * FROM ({left_sql}) AS t0 EXCEPT ALL SELECT * FROM ({right_sql}) AS t1" + + def _translate_substitutewithindex_table(self, call: Any) -> str: + if len(call.args) < 4: + raise DaxTranslationError( + "SUBSTITUTEWITHINDEX requires a source table, index column name, index table, and order-by expression" + ) + + left_expr = self._unwrap(call.args[0]) + left_sql = self._translate_table_with_isolated_base_context(left_expr, preserve_result_base=True) + + index_name = self._string_literal_value(call.args[1]) + if index_name is None: + raise DaxTranslationError("SUBSTITUTEWITHINDEX index column name must be a string") + + trailing_args = [self._unwrap(arg) for arg in call.args[2:]] + table_positions = [idx for idx, arg in enumerate(trailing_args) if self._is_table_expression_node(arg)] + if len(table_positions) != 1: + raise DaxTranslationError("SUBSTITUTEWITHINDEX requires exactly one index table argument") + + index_expr = trailing_args[table_positions[0]] + order_args = [arg for idx, arg in enumerate(trailing_args) if idx != table_positions[0]] + if not order_args: + raise DaxTranslationError("SUBSTITUTEWITHINDEX requires at least one order-by expression") + + index_sql = self._translate_table_with_isolated_base_context(index_expr) + left_cols = self._infer_table_expr_output_columns(left_expr, left_sql) + index_cols = self._infer_table_expr_output_columns(index_expr, index_sql) + if not left_cols or not index_cols: + raise DaxTranslationError("SUBSTITUTEWITHINDEX requires inferable source and index table columns") + left_col_counts = self._infer_table_expr_output_column_counts(left_expr, left_sql) + index_col_counts = self._infer_table_expr_output_column_counts(index_expr, index_sql) + + left_map = {name.lower(): name for name in sorted(left_cols, key=str.lower)} + index_map = {name.lower(): name for name in sorted(index_cols, key=str.lower)} + common_keys = sorted(set(left_map) & set(index_map)) + if not common_keys: + raise DaxTranslationError( + "SUBSTITUTEWITHINDEX requires at least one common column between source and index tables" + ) + for key in common_keys: + if left_col_counts.get(key, 0) > 1: + source_name = left_map.get(key, key) + raise DaxTranslationError( + f"SUBSTITUTEWITHINDEX source table has ambiguous common column '{source_name}'" + ) + if index_col_counts.get(key, 0) > 1: + source_name = index_map.get(key, key) + raise DaxTranslationError( + f"SUBSTITUTEWITHINDEX index table has ambiguous common column '{source_name}'" + ) + + index_tables = self._collect_table_references(index_expr) + order_by_parts = self._parse_substitutewithindex_order_by_parts( + order_args, + index_tables, + index_cols, + index_col_counts, + ) + + common_index_cols = [index_map[key] for key in common_keys] + group_cols_sql = ", ".join(f"i1.{self._quote_identifier(name)}" for name in common_index_cols) + ranked_alias = "__substitutewithindex_rank" + ranked_sql = ( + f"SELECT i0.*, DENSE_RANK() OVER (ORDER BY {', '.join(order_by_parts)}) AS {self._quote_identifier(ranked_alias)} " + f"FROM ({index_sql}) AS i0" + ) + mapping_sql = ( + f"SELECT {group_cols_sql}, MIN(i1.{self._quote_identifier(ranked_alias)}) AS {self._quote_identifier(ranked_alias)} " + f"FROM ({ranked_sql}) AS i1 GROUP BY {group_cols_sql}" + ) + + join_predicates = [ + f"l.{self._quote_identifier(left_map[key])} IS NOT DISTINCT FROM i.{self._quote_identifier(index_map[key])}" + for key in common_keys + ] + left_keep = [left_map[key] for key in sorted(left_map) if key not in common_keys] + projections = [f"l.{self._quote_identifier(name)}" for name in left_keep] + projections.append(f"i.{self._quote_identifier(ranked_alias)} AS {self._quote_identifier(index_name)}") + return ( + f"SELECT {', '.join(projections)} FROM ({left_sql}) AS l " + f"LEFT JOIN ({mapping_sql}) AS i ON {' AND '.join(join_predicates)}" + ) + + def _is_table_expression_node(self, expr: Any) -> bool: + if self._table_name_from_expr(expr) is not None: + return True + if isinstance(expr, self.dax.FunctionCall): + return self._is_table_function_name(expr.name.lower()) + if isinstance(expr, self.dax.TableConstructor): + return True + if isinstance(expr, self.dax.Identifier): + if self._is_known_measure_identifier(expr.name): + return False + return self._table_exists(expr.name) + return False + + def _parse_substitutewithindex_order_by_parts( + self, + order_args: list[Any], + source_tables: set[str], + source_columns: set[str], + source_column_counts: dict[str, int] | None = None, + ) -> list[str]: + parts: list[str] = [] + source_tables_lower = {table.lower() for table in source_tables} + source_columns_lower = {column.lower() for column in source_columns} + source_column_counts = source_column_counts or {} + idx = 0 + while idx < len(order_args): + expr = order_args[idx] + direction = "ASC" + if idx + 1 < len(order_args): + direction_ident = self._identifier_literal_value(order_args[idx + 1]) + if direction_ident is not None and direction_ident.upper() in ("ASC", "DESC"): + direction = direction_ident.upper() + idx += 2 + else: + idx += 1 + else: + idx += 1 + + with self._allow_cross_table_context(): + fragment = self._translate_scalar(expr) + for ref in fragment.columns: + if ref.table is not None and ref.table.lower() not in source_tables_lower: + raise DaxTranslationError( + "SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument" + ) + if ref.column.lower() not in source_columns_lower: + raise DaxTranslationError( + "SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument" + ) + if source_column_counts.get(ref.column.lower(), 0) > 1: + raise DaxTranslationError( + f"SUBSTITUTEWITHINDEX ORDER BY column '{ref.column}' is ambiguous in index table expression" + ) + try: + rewritten = _rewrite_expr_for_alias( + fragment.sql, + "i0", + source_tables=source_tables, + source_columns=source_columns, + allow_fallback=False, + strict_source_resolution=True, + ) + except DaxTranslationError as exc: + raise DaxTranslationError( + "SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument" + ) from exc + parts.append(f"{rewritten} {direction}") + return parts + + def _translate_calendar_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("CALENDAR requires start date and end date") + with self._allow_cross_table_context(): + start_fragment = self._translate_scalar(call.args[0]) + end_fragment = self._translate_scalar(call.args[1]) + start = self._scalar_fragment_sql_with_from(start_fragment) + end = self._scalar_fragment_sql_with_from(end_fragment) + return ( + "SELECT date_value AS Date FROM generate_series(" + f"CAST({start} AS DATE), CAST({end} AS DATE), INTERVAL '1 day'" + ") AS gs(date_value)" + ) + + def _translate_generateseries_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("GENERATESERIES requires start and end arguments") + with self._allow_cross_table_context(): + start_fragment = self._translate_scalar(call.args[0]) + end_fragment = self._translate_scalar(call.args[1]) + step_fragment = ( + self._translate_scalar(call.args[2]) if len(call.args) > 2 else _SqlFragment("1", frozenset()) + ) + start = self._scalar_fragment_sql_with_from(start_fragment) + end = self._scalar_fragment_sql_with_from(end_fragment) + step = self._scalar_fragment_sql_with_from(step_fragment) + return f"SELECT value FROM generate_series({start}, {end}, {step}) AS gs(value)" + + def _translate_selectcolumns(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("SELECTCOLUMNS requires a table and column pairs") + pairs = call.args[1:] + if len(pairs) % 2 != 0: + raise DaxTranslationError("SELECTCOLUMNS requires name/expression pairs") + + base_expr = self._unwrap(call.args[0]) + base_table_name = self._table_name_from_expr(base_expr) + select_parts = [] + if base_table_name is not None: + self._ensure_table_context(base_table_name) + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + for i in range(0, len(pairs), 2): + name_expr = pairs[i] + value_expr = pairs[i + 1] + alias = self._string_literal_value(name_expr) + if alias is None: + raise DaxTranslationError("SELECTCOLUMNS name must be a string") + fragment = self._translate_projection_scalar(value_expr) + select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + self._append_tables(tables_in_order, seen_tables, fragment.columns) + from_sql = self._build_from_clause_for_tables(tables_in_order) + else: + from_sql, wrapped = self._table_source_from_expr(call.args[0]) + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + if wrapped and self._base_table: + tables_in_order = [self._base_table] + seen_tables = {self._base_table.lower()} + for i in range(0, len(pairs), 2): + name_expr = pairs[i] + value_expr = pairs[i + 1] + alias = self._string_literal_value(name_expr) + if alias is None: + raise DaxTranslationError("SELECTCOLUMNS name must be a string") + fragment = ( + self._translate_projection_scalar(value_expr) if wrapped else self._translate_scalar(value_expr) + ) + select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + if wrapped and self._base_table: + self._append_tables(tables_in_order, seen_tables, fragment.columns) + if wrapped and self._base_table and len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) + + select_sql = ", ".join(select_parts) + return f"SELECT {select_sql} FROM {from_sql}" + + def _translate_addcolumns(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("ADDCOLUMNS requires a table and column pairs") + pairs = call.args[1:] + if len(pairs) % 2 != 0: + raise DaxTranslationError("ADDCOLUMNS requires name/expression pairs") + + base_expr = self._unwrap(call.args[0]) + base_table_name = self._table_name_from_expr(base_expr) + select_parts: list[str] = [] + if base_table_name is not None: + self._ensure_table_context(base_table_name) + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + for i in range(0, len(pairs), 2): + alias = self._string_literal_value(pairs[i]) + if alias is None: + raise DaxTranslationError("ADDCOLUMNS name must be a string") + fragment = self._translate_projection_scalar(pairs[i + 1]) + select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + self._append_tables(tables_in_order, seen_tables, fragment.columns) + from_sql = self._build_from_clause_for_tables(tables_in_order) + if len(tables_in_order) == 1: + select_parts.insert(0, "*") + else: + select_parts.insert(0, f"{self._table_sql(base_table_name)}.*") + else: + from_sql, wrapped = self._table_source_from_expr(call.args[0]) + select_parts = ["t.*" if wrapped else "*"] + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + if wrapped and self._base_table: + tables_in_order = [self._base_table] + seen_tables = {self._base_table.lower()} + for i in range(0, len(pairs), 2): + alias = self._string_literal_value(pairs[i]) + if alias is None: + raise DaxTranslationError("ADDCOLUMNS name must be a string") + fragment = ( + self._translate_projection_scalar(pairs[i + 1]) if wrapped else self._translate_scalar(pairs[i + 1]) + ) + select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + if wrapped and self._base_table: + self._append_tables(tables_in_order, seen_tables, fragment.columns) + if wrapped and self._base_table and len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) + + select_sql = ", ".join(select_parts) + return f"SELECT {select_sql} FROM {from_sql}" + + def _translate_summarizecolumns(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("SUMMARIZECOLUMNS requires arguments") + + group_by: list[str] = [] + measures: list[str] = [] + filters: list[str] = [] + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + group_by_columns: set[ColumnRef] = set() + filter_columns: set[ColumnRef] = set() + + args = list(call.args) + idx = 0 + with self._allow_cross_table_context(): + while idx < len(args): + arg = self._unwrap(args[idx]) + if isinstance(arg, self.dax.String): + break + group_by_args = self._extract_group_by_args(arg) + if group_by_args is not None: + for group_arg in group_by_args: + fragment = self._translate_scalar(group_arg) + group_by.append(fragment.sql) + group_by_columns.update(fragment.columns) + self._append_tables(tables_in_order, seen_tables, fragment.columns) + idx += 1 + continue + if isinstance(arg, self.dax.FunctionCall) and self._is_filter_table_candidate(arg): + filter_arg = arg + if arg.name.lower() in ("keepfilters", "nonvisual"): + inner = arg.args[0] if arg.args else None + if inner is None: + idx += 1 + continue + filter_arg = self._unwrap(inner) + clauses, _overrides = self._translate_filter_candidate(filter_arg, keep=False) + for clause in clauses: + filters.append(clause.sql) + filter_columns.update(clause.columns) + self._append_tables(tables_in_order, seen_tables, clause.columns) + idx += 1 + continue + if isinstance(arg, self.dax.FunctionCall): + try: + self._translate_table(arg) + except DaxTranslationError as exc: + if not _is_unsupported_table_expression_error(exc): + raise + else: + nested_filters, _overrides = self._filters_from_table(arg) + for clause_sql in nested_filters: + clause = self._filter_clause_from_sql(clause_sql, keep=False) + filters.append(clause.sql) + filter_columns.update(clause.columns) + self._append_tables(tables_in_order, seen_tables, clause.columns) + idx += 1 + continue + table_name = self._table_name_from_expr(arg) + if table_name is not None: + self._ensure_table_context(table_name) + if table_name.lower() not in seen_tables: + tables_in_order.append(table_name) + seen_tables.add(table_name.lower()) + idx += 1 + continue + raise DaxTranslationError("Unsupported SUMMARIZECOLUMNS argument") + + remaining = args[idx:] + if len(remaining) % 2 != 0: + raise DaxTranslationError("SUMMARIZECOLUMNS requires name/expression pairs") + + with self._measure_eval_context(group_by_columns, filter_columns): + for i in range(0, len(remaining), 2): + alias = self._string_literal_value(remaining[i]) + if alias is None: + raise DaxTranslationError("SUMMARIZECOLUMNS name must be a string") + fragment = self._translate_scalar(remaining[i + 1]) + measures.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + self._append_tables(tables_in_order, seen_tables, fragment.columns) + + if not group_by and not measures: + raise DaxTranslationError("SUMMARIZECOLUMNS produced no columns") + + from_clause = self._build_from_clause_for_tables(tables_in_order) + select_parts = group_by + measures + select_sql = ", ".join(select_parts) + group_by_sql = "" + if group_by: + group_by_sql = f" GROUP BY {', '.join(group_by)}" + where_sql = "" + if filters: + where_sql = f" WHERE {' AND '.join(filters)}" + return f"SELECT {select_sql} FROM {from_clause}{where_sql}{group_by_sql}" + + def _translate_summarize(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("SUMMARIZE requires a table and at least one group-by column") + + base_expr = call.args[0] + from_sql, wrapped = self._table_source_from_expr(base_expr) + base_table = self._base_table.lower() if self._base_table else None + + group_by: list[str] = [] + measures: list[str] = [] + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + group_by_columns: set[ColumnRef] = set() + + if base_table: + tables_in_order.append(self._base_table or "") + seen_tables.add(base_table) + + args = list(call.args[1:]) + idx = 0 + with self._allow_cross_table_context(): + qualifier_ctx = self._prefer_unqualified_base_table_context() if wrapped else nullcontext() + with qualifier_ctx: + while idx < len(args): + arg = self._unwrap(args[idx]) + if isinstance(arg, self.dax.String): + break + group_by_args = self._extract_group_by_args(arg) + if group_by_args is None and wrapped and isinstance(arg, self.dax.Identifier): + group_by_args = [arg] + if group_by_args is not None: + for group_arg in group_by_args: + if ( + wrapped + and base_table is None + and isinstance(group_arg, (self.dax.BracketRef, self.dax.Identifier)) + ): + fragment = _SqlFragment(self._quote_identifier(group_arg.name), frozenset()) + else: + fragment = self._translate_scalar(group_arg) + group_by.append(fragment.sql) + group_by_columns.update(fragment.columns) + self._append_tables(tables_in_order, seen_tables, fragment.columns) + idx += 1 + continue + raise DaxTranslationError("Unsupported SUMMARIZE group-by argument") + + remaining = args[idx:] + if len(remaining) % 2 != 0: + raise DaxTranslationError("SUMMARIZE requires name/expression pairs after group-by columns") + + with self._measure_eval_context(group_by_columns, set()): + for i in range(0, len(remaining), 2): + alias = self._string_literal_value(remaining[i]) + if alias is None: + raise DaxTranslationError("SUMMARIZE name must be a string") + fragment = self._translate_scalar(remaining[i + 1]) + measures.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") + self._append_tables(tables_in_order, seen_tables, fragment.columns) + + if not group_by and not measures: + raise DaxTranslationError("SUMMARIZE produced no columns") + + if wrapped: + if base_table: + from_clause = self._build_from_clause_for_wrapped_base( + from_sql, + self._base_table or "", + tables_in_order, + ) + else: + from_clause = from_sql + else: + from_clause = self._build_from_clause_for_tables(tables_in_order) + select_parts = group_by + measures + select_sql = ", ".join(select_parts) + group_by_sql = "" + if group_by: + group_by_sql = f" GROUP BY {', '.join(group_by)}" + return f"SELECT {select_sql} FROM {from_clause}{group_by_sql}" + + def _build_from_clause_for_wrapped_base(self, from_sql: str, base_table: str, tables_in_order: list[str]) -> str: + if not tables_in_order: + return from_sql + + base_key = base_table.lower() + from_parts = [from_sql] + joined_tables = {base_key} + joined_order = [base_table] + + for table in tables_in_order: + table_key = table.lower() + if table_key in joined_tables: + continue + path = self._find_relationship_path_from_joined(joined_order, table) + if path is None: + if self._allow_unrelated_table_cross_join: + self._append_unrelated_cross_join_warning(base_table, table) + from_parts.append(f"CROSS JOIN {self._table_sql(table)}") + joined_tables.add(table_key) + joined_order.append(table) + continue + raise DaxTranslationError(f"No relationship path between {base_table} and {table}") + + for from_table, to_table, from_col, to_col in path: + to_key = to_table.lower() + if to_key in joined_tables: + continue + left_table = "t" if from_table.lower() == base_key else self._table_sql(from_table) + right_table = self._table_sql(to_table) + from_col_sql = self._quote_identifier(from_col) + to_col_sql = self._quote_identifier(to_col) + from_parts.append( + f"LEFT JOIN {right_table} ON {left_table}.{from_col_sql} = {right_table}.{to_col_sql}" + ) + joined_tables.add(to_key) + joined_order.append(to_table) + + return " ".join(from_parts) + + def _extract_group_by_args(self, expr: Any) -> list[Any] | None: + expr = self._unwrap(expr) + if isinstance(expr, (self.dax.TableColumnRef, self.dax.HierarchyRef, self.dax.BracketRef)): + return [expr] + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() in ( + "rollup", + "rollupgroup", + "rollupissubtotal", + "rollupaddissubtotal", + ): + group_by_args: list[Any] = [] + if expr.name.lower() == "rollupaddissubtotal": + idx = 0 + while idx < len(expr.args): + current = self._unwrap(expr.args[idx]) + if isinstance(current, self.dax.String): + idx += 1 + continue + nested = self._extract_group_by_args(current) + if nested is None: + raise DaxTranslationError( + f"{expr.name} only supports column and hierarchy references in this context" + ) + group_by_args.extend(nested) + idx += 1 + if idx < len(expr.args) and isinstance(self._unwrap(expr.args[idx]), self.dax.String): + idx += 1 + else: + for arg in expr.args: + nested = self._extract_group_by_args(arg) + if nested is None: + raise DaxTranslationError( + f"{expr.name} only supports column and hierarchy references in this context" + ) + group_by_args.extend(nested) + if not group_by_args: + raise DaxTranslationError(f"{expr.name} requires at least one group-by argument") + return group_by_args + return None + + def _translate_topn(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("TOPN requires count and table") + count_sql = self._topn_numeric_arg_sql(call.args[0], function_name="TOPN", arg_name="count") + table_expr = self._unwrap(call.args[1]) + base_table_name = self._table_name_from_expr(table_expr) + if base_table_name is not None: + self._ensure_table_context(base_table_name) + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + order_by_parts, order_columns = self._parse_order_by_parts_with_columns( + call.args[2:], + projection_safe=True, + ) + self._append_tables(tables_in_order, seen_tables, order_columns) + from_sql = self._build_from_clause_for_tables(tables_in_order) + select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" + order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" + return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql}" + + from_sql, wrapped = self._table_source_from_expr(call.args[1]) + order_by_parts, order_columns = self._parse_order_by_parts_with_columns( + call.args[2:], + projection_safe=wrapped, + ) + select_sql = "*" + if wrapped and self._base_table: + tables_in_order = [self._base_table] + seen_tables = {self._base_table.lower()} + self._append_tables(tables_in_order, seen_tables, order_columns) + if len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) + select_sql = "t.*" + order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" + return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql}" + + def _translate_topnperlevel(self, call: Any) -> str: + if len(call.args) < 3: + raise DaxTranslationError("TOPNPERLEVEL requires count, group-by column(s), and table") + count_sql = self._topn_numeric_arg_sql(call.args[0], function_name="TOPNPERLEVEL", arg_name="count") + table_idx = self._topnperlevel_table_index(call) + if table_idx is None: + raise DaxTranslationError("TOPNPERLEVEL requires a table argument") + table_expr = self._unwrap(call.args[table_idx]) + base_table_name = self._table_name_from_expr(table_expr) + wrapped = False + from_sql: str | None = None + if base_table_name is None: + from_sql, wrapped = self._table_source_from_expr(call.args[table_idx]) + if wrapped and self._base_table: + base_table_name = self._base_table + if base_table_name is not None: + self._ensure_table_context(base_table_name) + group_by_parts, group_by_columns = self._topnperlevel_group_by_parts( + call, + table_idx, + projection_safe=wrapped, + ) + if not group_by_parts: + raise DaxTranslationError("TOPNPERLEVEL requires at least one group-by column") + + if base_table_name is not None and not wrapped: + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + self._append_tables(tables_in_order, seen_tables, group_by_columns) + order_by_parts, order_by_columns = self._parse_order_by_parts_with_columns( + call.args[table_idx + 1 :], + projection_safe=True, + ) + self._append_tables(tables_in_order, seen_tables, order_by_columns) + from_sql = self._build_from_clause_for_tables(tables_in_order) + row_projection = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" + if not order_by_parts: + order_by_parts = list(group_by_parts) + rank_alias = "__topnperlevel_rank" + ranked_sql = ( + f"SELECT {row_projection}, RANK() OVER (PARTITION BY {', '.join(group_by_parts)} " + f"ORDER BY {', '.join(order_by_parts)}) AS {rank_alias} FROM {from_sql}" + ) + return f"SELECT * EXCLUDE ({rank_alias}) FROM ({ranked_sql}) AS q WHERE {rank_alias} <= {count_sql}" + + if wrapped and base_table_name and from_sql is not None: + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + self._append_tables(tables_in_order, seen_tables, group_by_columns) + order_by_parts, order_by_columns = self._parse_order_by_parts_with_columns( + call.args[table_idx + 1 :], + projection_safe=True, + ) + self._append_tables(tables_in_order, seen_tables, order_by_columns) + row_projection = "*" + if len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_wrapped_base(from_sql, base_table_name, tables_in_order) + row_projection = "t.*" + if not order_by_parts: + order_by_parts = list(group_by_parts) + rank_alias = "__topnperlevel_rank" + ranked_sql = ( + f"SELECT {row_projection}, RANK() OVER (PARTITION BY {', '.join(group_by_parts)} " + f"ORDER BY {', '.join(order_by_parts)}) AS {rank_alias} FROM {from_sql}" + ) + return f"SELECT * EXCLUDE ({rank_alias}) FROM ({ranked_sql}) AS q WHERE {rank_alias} <= {count_sql}" + + if from_sql is None: + from_sql, _wrapped = self._table_source_from_expr(call.args[table_idx]) + order_by_parts, _order_columns = self._parse_order_by_parts_with_columns( + call.args[table_idx + 1 :], + projection_safe=wrapped, + ) + if not order_by_parts: + order_by_parts = list(group_by_parts) + rank_alias = "__topnperlevel_rank" + ranked_sql = ( + f"SELECT *, RANK() OVER (PARTITION BY {', '.join(group_by_parts)} " + f"ORDER BY {', '.join(order_by_parts)}) AS {rank_alias} FROM {from_sql}" + ) + return f"SELECT * EXCLUDE ({rank_alias}) FROM ({ranked_sql}) AS q WHERE {rank_alias} <= {count_sql}" + + def _translate_topnskip(self, call: Any) -> str: + if len(call.args) < 3: + raise DaxTranslationError("TOPNSKIP requires count, skip, and table") + count_sql = self._topn_numeric_arg_sql(call.args[0], function_name="TOPNSKIP", arg_name="count") + skip_sql = self._topn_numeric_arg_sql(call.args[1], function_name="TOPNSKIP", arg_name="skip") + table_expr = self._unwrap(call.args[2]) + base_table_name = self._table_name_from_expr(table_expr) + if base_table_name is not None: + self._ensure_table_context(base_table_name) + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + order_by_parts, order_columns = self._parse_order_by_parts_with_columns( + call.args[3:], + projection_safe=True, + ) + self._append_tables(tables_in_order, seen_tables, order_columns) + from_sql = self._build_from_clause_for_tables(tables_in_order) + select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" + order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" + return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql} OFFSET {skip_sql}" + + from_sql, wrapped = self._table_source_from_expr(call.args[2]) + order_by_parts, order_columns = self._parse_order_by_parts_with_columns( + call.args[3:], + projection_safe=wrapped, + ) + select_sql = "*" + if wrapped and self._base_table: + tables_in_order = [self._base_table] + seen_tables = {self._base_table.lower()} + self._append_tables(tables_in_order, seen_tables, order_columns) + if len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) + select_sql = "t.*" + order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" + return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql} OFFSET {skip_sql}" + + def _topnperlevel_table_index(self, call: Any) -> int | None: + for idx, arg in enumerate(call.args[1:], start=1): + candidate = self._unwrap(arg) + if self._table_name_from_expr(candidate) is not None: + return idx + if isinstance(candidate, (self.dax.FunctionCall, self.dax.TableConstructor)): + return idx + return None + + def _topnperlevel_group_by_parts( + self, + call: Any, + table_idx: int, + *, + projection_safe: bool = False, + ) -> tuple[list[str], set[ColumnRef]]: + group_parts: list[str] = [] + columns: set[ColumnRef] = set() + context = nullcontext() if projection_safe else self._allow_cross_table_context() + with context: + for raw_arg in call.args[1:table_idx]: + group_args = self._extract_group_by_args(raw_arg) + if group_args is None: + raise DaxTranslationError("TOPNPERLEVEL group-by arguments must be column or hierarchy references") + for group_arg in group_args: + fragment = ( + self._translate_projection_scalar(group_arg) + if projection_safe + else self._translate_scalar(group_arg) + ) + group_parts.append(fragment.sql) + columns.update(fragment.columns) + return group_parts, columns + + def _parse_order_by_parts(self, args: list[Any]) -> list[str]: + order_by_parts, _columns = self._parse_order_by_parts_with_columns(args) + return order_by_parts + + def _parse_order_by_parts_with_columns( + self, + args: list[Any], + *, + projection_safe: bool = False, + ) -> tuple[list[str], set[ColumnRef]]: + order_by_parts: list[str] = [] + columns: set[ColumnRef] = set() + idx = 0 + while idx < len(args): + expr = args[idx] + direction = None + if idx + 1 < len(args): + direction = self._identifier_literal_value(args[idx + 1]) + if direction is not None and direction.upper() in ("ASC", "DESC"): + idx += 2 + else: + direction = None + idx += 1 + else: + idx += 1 + fragment = self._translate_projection_scalar(expr) if projection_safe else self._translate_scalar(expr) + expr_sql = fragment.sql + columns.update(fragment.columns) + if direction: + order_by_parts.append(f"{expr_sql} {direction.upper()}") + else: + order_by_parts.append(expr_sql) + return order_by_parts, columns + + def _translate_groupby(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("GROUPBY requires a table and at least one group-by column") + return self._translate_summarize(call) + + def _translate_generate_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError(f"{call.name} requires at least two table arguments") + left_sql = self._translate_table(call.args[0]) + try: + right_sql = self._translate_table(call.args[1]) + except DaxTranslationError as exc: + if str(exc) != "DAX table expressions must reference a single base table": + raise + with self._allow_cross_table_context(): + right_sql = self._translate_table(call.args[1]) + left_tables = self._collect_table_references(call.args[0]) + left_columns = _query_output_columns(left_sql) + if not left_columns and left_tables and _query_uses_star_projection(left_sql): + left_columns = self._known_columns_for_tables(left_tables) + ambiguous_left_columns = self._ambiguous_columns_for_tables(left_tables) + left_column_aliases, ambiguous_left_lineage_aliases = _query_output_lineage_aliases(left_sql) + right_source_tables = _query_source_table_names(right_sql) + right_local_columns = self._known_columns_for_tables(right_source_tables) + if left_tables: + right_sql = _rewrite_expr_for_alias( + right_sql, + "l", + source_tables=left_tables, + source_columns=left_columns, + source_column_aliases=left_column_aliases, + ambiguous_source_aliases=ambiguous_left_lineage_aliases, + local_columns=right_local_columns, + ambiguous_source_columns=ambiguous_left_columns, + allow_fallback=False, + strict_source_resolution=True, + ) + if call.name.lower() == "generateall": + return f"SELECT * FROM ({left_sql}) AS l LEFT JOIN LATERAL ({right_sql}) AS r ON TRUE" + return f"SELECT * FROM ({left_sql}) AS l CROSS JOIN LATERAL ({right_sql}) AS r" + + def _translate_table_with_isolated_base_context(self, expr: Any, *, preserve_result_base: bool = False) -> str: + prior_base_table = self._base_table + self._base_table = None + try: + sql = self._translate_table(expr) + translated_base_table = self._base_table + finally: + self._base_table = prior_base_table + if preserve_result_base and prior_base_table is None and translated_base_table is not None: + self._base_table = translated_base_table + return sql + + def _translate_addmissingitems_table(self, call: Any) -> str: + table_idx = self._addmissingitems_table_index(call) + if table_idx is None: + raise DaxTranslationError("ADDMISSINGITEMS requires a table expression argument") + table_arg = call.args[table_idx] + base_sql = self._translate_table(table_arg) + + group_specs: list[Any] = [] + domain_filter_clauses: list[str] = [] + domain_filter_columns: set[ColumnRef] = set() + other_args = [arg for idx, arg in enumerate(call.args) if idx != table_idx] + for candidate_arg in other_args: + if self._extract_group_by_args(candidate_arg) is not None: + group_specs.append(candidate_arg) + continue + with self._allow_cross_table_context(): + nested_filters, _overrides = self._filters_from_table(candidate_arg) + domain_filter_clauses.extend(nested_filters) + for clause_sql in nested_filters: + domain_filter_columns.update(self._columns_from_sql(clause_sql)) + + if not group_specs: + return base_sql + + group_parts: list[_SqlFragment] = [] + seen_group_sql: set[str] = set() + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + with self._allow_cross_table_context(): + for raw_arg in group_specs: + group_args = self._extract_group_by_args(raw_arg) + if group_args is None: + raise DaxTranslationError( + "ADDMISSINGITEMS group-by arguments must be column or hierarchy references" + ) + for group_arg in group_args: + fragment = self._translate_scalar(group_arg) + group_key = fragment.sql.strip().lower() + if group_key in seen_group_sql: + continue + seen_group_sql.add(group_key) + group_parts.append(fragment) + self._append_tables(tables_in_order, seen_tables, fragment.columns) + + if not group_parts or not tables_in_order: + return base_sql + + if domain_filter_columns: + self._append_tables(tables_in_order, seen_tables, domain_filter_columns) + + domain_selects: list[str] = [] + projections: list[str] = [] + projection_names: list[str] = [] + join_predicates: list[str] = [] + base_output_cols = _query_output_columns(base_sql) + base_output_keys = {name.lower() for name in base_output_cols if name and name != "*"} + for idx, fragment in enumerate(group_parts): + key_alias = f"__addmissingitems_k{idx}" + output_name = _column_name_from_expr_sql(fragment.sql) or f"value{idx + 1}" + domain_selects.append(f"{fragment.sql} AS {key_alias}") + projections.append(f"d.{key_alias} AS {self._quote_identifier(output_name)}") + projection_names.append(output_name) + if output_name.lower() in base_output_keys: + base_expr = _rewrite_expr_for_alias(fragment.sql, "b") + join_predicates.append(f"{base_expr} IS NOT DISTINCT FROM d.{key_alias}") + + domain_from = self._build_from_clause_for_tables(tables_in_order) + domain_where = f" WHERE {' AND '.join(domain_filter_clauses)}" if domain_filter_clauses else "" + domain_sql = f"SELECT DISTINCT {', '.join(domain_selects)} FROM {domain_from}{domain_where}" + on_sql = " AND ".join(join_predicates) if join_predicates else "TRUE" + projected_name_keys = {name.lower() for name in projection_names} + duplicate_base_cols = sorted( + (name for name in base_output_cols if name and name != "*" and name.lower() in projected_name_keys), + key=str.lower, + ) + base_select = "b.*" + if duplicate_base_cols: + excluded_cols = ", ".join(self._quote_identifier(name) for name in duplicate_base_cols) + base_select = f"b.* EXCLUDE ({excluded_cols})" + select_sql = ", ".join([*projections, base_select]) + return f"SELECT {select_sql} FROM ({domain_sql}) AS d LEFT JOIN ({base_sql}) AS b ON {on_sql}" + + def _translate_datatable_table(self, call: Any) -> str: + if len(call.args) < 3: + raise DaxTranslationError("DATATABLE requires column definitions and row values") + rows_expr = self._unwrap(call.args[-1]) + if not isinstance(rows_expr, self.dax.TableConstructor): + raise DaxTranslationError("DATATABLE requires a table-constructor row argument") + + column_args = list(call.args[:-1]) + if len(column_args) % 2 != 0: + raise DaxTranslationError("DATATABLE requires name/datatype column pairs") + + columns: list[str] = [] + for idx in range(0, len(column_args), 2): + col_name = self._string_literal_value(column_args[idx]) + if col_name is None: + raise DaxTranslationError("DATATABLE column names must be strings") + columns.append(col_name) + + if not columns: + raise DaxTranslationError("DATATABLE requires at least one column") + + row_values_sql: list[str] = [] + for row in rows_expr.rows: + normalized_row = self._normalize_datatable_row(row, len(columns)) + if normalized_row is None: + raise DaxTranslationError("DATATABLE row width must match column definition count") + fragments = [self._translate_scalar(value) for value in normalized_row] + row_values_sql.append("(" + ", ".join(fragment.sql for fragment in fragments) + ")") + + aliased_columns = ", ".join(self._quote_identifier(name) for name in columns) + if not row_values_sql: + nulls = ", ".join(f"CAST(NULL AS VARCHAR) AS {self._quote_identifier(name)}" for name in columns) + return f"SELECT {nulls} WHERE 1 = 0" + return f"SELECT * FROM (VALUES {', '.join(row_values_sql)}) AS t({aliased_columns})" + + def _translate_relatedtable_table(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("RELATEDTABLE requires a table argument") + target = self._unwrap(call.args[0]) + table_name = self._table_name_from_expr(target) + if table_name is None: + raise DaxTranslationError("RELATEDTABLE requires a table reference argument") + with self._allow_cross_table_context(): + self._ensure_table_context(table_name) + return f"SELECT * FROM {self._table_sql(table_name)}" + + def _normalize_datatable_row(self, row: list[Any], width: int) -> list[Any] | None: + if len(row) == width: + return row + if len(row) == 1: + nested = self._unwrap(row[0]) + if isinstance(nested, self.dax.TableConstructor): + flattened: list[Any] = [] + for nested_row in nested.rows: + if len(nested_row) != 1: + return None + flattened.append(nested_row[0]) + if len(flattened) == width: + return flattened + return None + + def _addmissingitems_table_arg(self, call: Any) -> Any | None: + table_idx = self._addmissingitems_table_index(call) + if table_idx is not None: + return call.args[table_idx] + return None + + def _addmissingitems_table_index(self, call: Any) -> int | None: + group_tables = self._addmissingitems_group_tables(call) + non_group_candidates: list[tuple[int, Any]] = [] + for idx, arg in enumerate(call.args): + candidate = self._unwrap(arg) + if self._extract_group_by_args(candidate) is not None: + continue + if isinstance(candidate, (self.dax.TableRef, self.dax.Identifier, self.dax.TableConstructor)): + non_group_candidates.append((idx, candidate)) + continue + if isinstance(candidate, self.dax.FunctionCall): + non_group_candidates.append((idx, candidate)) + + if not non_group_candidates: + return None + + for idx, candidate in non_group_candidates: + core_candidate = self._addmissingitems_table_core_expr(candidate) + if isinstance(core_candidate, self.dax.FunctionCall) and core_candidate.name.lower() == "summarizecolumns": + return idx + for idx, candidate in non_group_candidates: + core_candidate = self._addmissingitems_table_core_expr(candidate) + if self._is_addmissingitems_strong_preferred_table_core(core_candidate): + return idx + for idx, candidate in non_group_candidates: + core_candidate = self._addmissingitems_table_core_expr(candidate) + if self._is_addmissingitems_preferred_table_core( + core_candidate + ) and self._addmissingitems_core_has_non_group_table(core_candidate, group_tables): + return idx + for idx, candidate in non_group_candidates: + core_candidate = self._addmissingitems_table_core_expr(candidate) + if self._is_addmissingitems_likely_main_table_core(core_candidate, group_tables): + return idx + for idx, candidate in non_group_candidates: + core_candidate = self._addmissingitems_table_core_expr(candidate) + if self._is_addmissingitems_preferred_table_core(core_candidate): + return idx + + non_filter_candidates = [ + idx for idx, candidate in non_group_candidates if not self._is_filter_table_candidate(candidate) + ] + if non_filter_candidates: + return non_filter_candidates[0] + + for idx, candidate in non_group_candidates: + if not self._is_explicit_filter_wrapper(candidate): + return idx + + return non_group_candidates[0][0] + + def _addmissingitems_group_tables(self, call: Any) -> set[str]: + tables: set[str] = set() + for arg in call.args: + candidate = self._unwrap(arg) + group_args = self._extract_group_by_args(candidate) + if group_args is None: + continue + for group_arg in group_args: + tables.update(self._collect_table_references(group_arg)) + return {table.lower() for table in tables} + + def _addmissingitems_core_has_non_group_table(self, expr: Any, group_tables: set[str]) -> bool: + tables = {table.lower() for table in self._collect_table_references(expr)} + if not tables: + return False + return any(table not in group_tables for table in tables) + + def _addmissingitems_table_core_expr(self, expr: Any) -> Any: + expr = self._unwrap(expr) + if not isinstance(expr, self.dax.FunctionCall): + return expr + name = expr.name.lower() + if ( + name in ("keepfilters", "nonvisual", "filter", "renamecolumns", "keepcolumns", "removecolumns") + and expr.args + ): + return self._addmissingitems_table_core_expr(expr.args[0]) + if name == "calculatetable" and expr.args: + return self._addmissingitems_table_core_expr(expr.args[0]) + return expr + + def _is_addmissingitems_preferred_table_core(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if not isinstance(expr, self.dax.FunctionCall): + return False + name = expr.name.lower() + return name in ( + "summarize", + "groupby", + "row", + "selectcolumns", + "addcolumns", + "renamecolumns", + "keepcolumns", + "removecolumns", + "topn", + "topnskip", + "topnperlevel", + "union", + "crossjoin", + "naturalinnerjoin", + "naturalleftouterjoin", + "intersect", + "except", + "generate", + "generateall", + "calendar", + "generateseries", + "datatable", + ) + + def _is_addmissingitems_strong_preferred_table_core(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if not isinstance(expr, self.dax.FunctionCall): + return False + name = expr.name.lower() + return name in ( + "summarize", + "groupby", + "row", + "selectcolumns", + "addcolumns", + "renamecolumns", + "keepcolumns", + "removecolumns", + "topn", + "topnskip", + "topnperlevel", + "generate", + "generateall", + ) + + def _is_addmissingitems_likely_main_table_core(self, expr: Any, group_tables: set[str]) -> bool: + expr = self._unwrap(expr) + table_name = self._table_name_from_expr(expr) + if table_name is None: + return self._addmissingitems_core_has_non_group_table(expr, group_tables) + return table_name.lower() not in group_tables + + def _is_filter_table_candidate(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if not isinstance(expr, self.dax.FunctionCall): + return False + name = expr.name.lower() + return name in ( + "filter", + "calculatetable", + "keepfilters", + "nonvisual", + "treatas", + "datesbetween", + "datesinperiod", + "datesytd", + "datesmtd", + "datesqtd", + "dateswtd", + "sameperiodlastyear", + "dateadd", + "parallelperiod", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + "all", + "allnoblankrow", + "allselected", + "allcrossfiltered", + "removefilters", + "allexcept", + "values", + "filters", + "distinct", + "renamecolumns", + "keepcolumns", + "removecolumns", + "substitutewithindex", + "union", + "crossjoin", + "naturalinnerjoin", + "naturalleftouterjoin", + "intersect", + "except", + ) + + def _is_table_filter_candidate_expr(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.TableRef): + return True + if isinstance(expr, self.dax.Identifier): + if self._is_known_measure_identifier(expr.name): + return False + return self._table_exists(expr.name) + return False + + def _is_known_measure_identifier(self, name: str) -> bool: + if self.model_name: + model_key = self.model_name.lower() + for table, measure_names in self.measure_names_by_table.items(): + if table.lower() != model_key: + continue + if name in measure_names: + return True + lower = name.lower() + return any(known.lower() == lower for known in measure_names) + return False + return self._resolve_measure_reference(name) is not None + + def _table_exists(self, name: str) -> bool: + key = name.lower() + for table_name in self.column_sql_by_table: + if table_name.lower() == key: + return True + for table_name in self.measure_names_by_table: + if table_name.lower() == key: + return True + return False + + def _is_explicit_filter_wrapper(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if not isinstance(expr, self.dax.FunctionCall): + return False + name = expr.name.lower() + return name in ( + "filter", + "calculatetable", + "keepfilters", + "nonvisual", + "treatas", + "datesbetween", + "datesinperiod", + "datesytd", + "datesmtd", + "datesqtd", + "dateswtd", + "sameperiodlastyear", + "dateadd", + "parallelperiod", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + "all", + "allnoblankrow", + "allselected", + "allcrossfiltered", + "removefilters", + "allexcept", + ) + + def _collect_table_references(self, expr: Any) -> set[str]: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.TableRef): + return {expr.table.name} + if isinstance(expr, self.dax.TableColumnRef): + return {expr.table.name} + if isinstance(expr, self.dax.HierarchyRef): + return {expr.table.name} + if isinstance(expr, self.dax.Identifier): + return {expr.name} + if isinstance(expr, self.dax.FunctionCall): + tables: set[str] = set() + for arg in expr.args: + tables.update(self._collect_table_references(arg)) + return tables + if isinstance(expr, self.dax.TableConstructor): + tables: set[str] = set() + for row in expr.rows: + for value in row: + tables.update(self._collect_table_references(value)) + return tables + if isinstance(expr, self.dax.Unary): + return self._collect_table_references(expr.expr) + if isinstance(expr, self.dax.Binary): + return self._collect_table_references(expr.left) | self._collect_table_references(expr.right) + if isinstance(expr, self.dax.VarBlock): + tables: set[str] = set() + for decl in expr.decls: + tables.update(self._collect_table_references(decl.expr)) + tables.update(self._collect_table_references(expr.body)) + return tables + if isinstance(expr, self.dax.Paren): + return self._collect_table_references(expr.expr) + return set() + + def _known_columns_for_tables(self, tables: set[str]) -> set[str]: + columns: set[str] = set() + for table in tables: + column_map = self._column_map_for_table(table) + for source_name, mapped in column_map.items(): + columns.add(source_name) + mapped_name = _identifier_name_from_sql(mapped) + if mapped_name: + columns.add(mapped_name) + return columns + + def _column_map_for_table(self, table: str) -> dict[str, str]: + if table in self.column_sql_by_table: + return self.column_sql_by_table[table] + table_key = table.lower() + for table_name, column_map in self.column_sql_by_table.items(): + if table_name.lower() == table_key: + return column_map + return {} + + def _known_table_references(self, expr: Any) -> set[str]: + known: set[str] = set() + for table in self._collect_table_references(expr): + resolved = self._resolve_known_table_name(table) + if resolved is not None: + known.add(resolved) + return known + + def _resolve_known_table_name(self, table: str) -> str | None: + if table in self.column_sql_by_table or table in self.measure_names_by_table: + return table + key = table.lower() + for table_name in self.column_sql_by_table: + if table_name.lower() == key: + return table_name + for table_name in self.measure_names_by_table: + if table_name.lower() == key: + return table_name + return None + + def _table_has_known_column(self, table: str, column: str) -> bool: + column_key = column.lower() + for source_name, mapped in self._column_map_for_table(table).items(): + if source_name.lower() == column_key: + return True + mapped_name = _identifier_name_from_sql(mapped) + if mapped_name and mapped_name.lower() == column_key: + return True + return False + + def _infer_table_expr_output_columns(self, expr: Any, sql: str) -> set[str]: + shape_columns = self._infer_table_expr_output_columns_by_shape(expr) + columns = _query_output_columns(sql) + uses_star = _query_uses_star_projection(sql) + if columns and not uses_star: + return columns + tables = self._collect_table_references(expr) + if tables and not columns and uses_star: + return self._known_columns_for_tables(tables) + if columns and uses_star: + star_qualifiers = _query_star_projection_qualifiers(sql) + qualified_star_tables = {name for name in star_qualifiers if name and self._table_exists(name)} + if qualified_star_tables: + return columns | self._known_columns_for_tables(qualified_star_tables) + if None in star_qualifiers and len(tables) == 1: + return columns | self._known_columns_for_tables(tables) + if shape_columns: + return shape_columns + return set() + if shape_columns: + return shape_columns + return set() + + def _infer_table_expr_output_columns_by_shape(self, expr: Any) -> set[str]: + counts = self._infer_table_expr_output_column_counts_by_shape(expr) + names: set[str] = set() + for key in counts: + names.add(key) + return names + + def _infer_table_expr_output_column_counts(self, expr: Any, sql: str) -> dict[str, int]: + counts = self._infer_table_expr_output_column_counts_by_shape(expr) + sql_counts = _query_output_column_name_counts(sql) + if not counts: + return sql_counts + if not sql_counts: + return counts + merged = dict(sql_counts) + for key, value in counts.items(): + merged[key] = max(merged.get(key, 0), value) + return merged + + def _infer_table_expr_output_column_counts_by_shape(self, expr: Any) -> dict[str, int]: + expr = self._unwrap(expr) + + table_name = self._table_name_from_expr(expr) + if table_name is not None: + counts: dict[str, int] = {} + for column_name in self._known_columns_for_tables({table_name}): + counts[column_name.lower()] = 1 + return counts + + if isinstance(expr, self.dax.FunctionCall): + name = expr.name.lower() + passthrough_first_arg = { + "filter", + "calculatetable", + "keepfilters", + "nonvisual", + "distinct", + "all", + "allnoblankrow", + "allselected", + "allcrossfiltered", + "removefilters", + } + if name in passthrough_first_arg and expr.args: + return self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) + if name == "topn" and len(expr.args) >= 2: + return self._infer_table_expr_output_column_counts_by_shape(expr.args[1]) + if name == "topnskip" and len(expr.args) >= 3: + return self._infer_table_expr_output_column_counts_by_shape(expr.args[2]) + if name == "topnperlevel": + table_idx = self._topnperlevel_table_index(expr) + if table_idx is not None and table_idx < len(expr.args): + return self._infer_table_expr_output_column_counts_by_shape(expr.args[table_idx]) + return {} + if name == "selectcolumns": + if len(expr.args) < 1: + return {} + pairs = expr.args[1:] + if len(pairs) % 2 != 0: + return {} + counts: dict[str, int] = {} + for i in range(0, len(pairs), 2): + alias = self._string_literal_value(pairs[i]) + if alias is None: + return {} + alias_key = alias.lower() + counts[alias_key] = counts.get(alias_key, 0) + 1 + return counts + if name == "addcolumns": + if not expr.args: + return {} + base = self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) + if not base: + return {} + pairs = expr.args[1:] + if len(pairs) % 2 != 0: + return {} + out = dict(base) + for i in range(0, len(pairs), 2): + alias = self._string_literal_value(pairs[i]) + if alias is None: + return {} + alias_key = alias.lower() + out[alias_key] = out.get(alias_key, 0) + 1 + return out + if name == "keepcolumns": + if len(expr.args) < 2: + return {} + keep_counts: dict[str, int] = {} + for raw_arg in expr.args[1:]: + try: + keep_name = self._table_column_arg_name(raw_arg, function_name="KEEPCOLUMNS") + except DaxTranslationError: + return {} + keep_counts.setdefault(keep_name.lower(), 1) + return keep_counts + if name == "removecolumns": + if len(expr.args) < 2: + return {} + base = self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) + if not base: + return {} + remove_keys: set[str] = set() + for raw_arg in expr.args[1:]: + try: + remove_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS") + except DaxTranslationError: + return {} + remove_keys.add(remove_name.lower()) + return {key: value for key, value in base.items() if key not in remove_keys} + if name == "renamecolumns": + if len(expr.args) < 3: + return {} + pairs = expr.args[1:] + if len(pairs) % 2 != 0: + return {} + base = self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) + if not base: + return {} + out = dict(base) + for i in range(0, len(pairs), 2): + try: + source = self._table_column_arg_name(pairs[i], function_name="RENAMECOLUMNS") + target = self._table_column_arg_name(pairs[i + 1], function_name="RENAMECOLUMNS") + except DaxTranslationError: + return {} + source_key = source.lower() + target_key = target.lower() + source_count = out.pop(source_key, None) + if source_count is None: + return {} + out[target_key] = out.get(target_key, 0) + source_count + return out + if name in ("union", "intersect", "except") and expr.args: + return self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) + if name in ( + "crossjoin", + "naturalinnerjoin", + "naturallefterouterjoin", + "naturallefterjoin", + "naturalleftouterjoin", + "generate", + "generateall", + ): + counts: dict[str, int] = {} + for arg in expr.args: + nested = self._infer_table_expr_output_column_counts_by_shape(arg) + for key, value in nested.items(): + counts[key] = counts.get(key, 0) + value + return counts + if name == "row": + if len(expr.args) < 2 or len(expr.args) % 2 != 0: + return {} + counts: dict[str, int] = {} + for i in range(0, len(expr.args), 2): + alias = self._string_literal_value(expr.args[i]) + if alias is None: + return {} + alias_key = alias.lower() + counts[alias_key] = counts.get(alias_key, 0) + 1 + return counts + if name == "datatable": + if len(expr.args) < 3: + return {} + column_args = list(expr.args[:-1]) + if len(column_args) % 2 != 0: + return {} + counts: dict[str, int] = {} + for i in range(0, len(column_args), 2): + col_name = self._string_literal_value(column_args[i]) + if col_name is None: + return {} + col_key = col_name.lower() + counts[col_key] = counts.get(col_key, 0) + 1 + return counts + + return {} + + def _ambiguous_columns_for_tables(self, tables: set[str]) -> set[str]: + counts: dict[str, int] = {} + for table in tables: + column_map = self.column_sql_by_table.get(table, {}) + table_columns: set[str] = set() + for source_name, mapped in column_map.items(): + table_columns.add(source_name.lower()) + mapped_name = _identifier_name_from_sql(mapped) + if mapped_name: + table_columns.add(mapped_name.lower()) + for column_name in table_columns: + counts[column_name] = counts.get(column_name, 0) + 1 + return {column_name for column_name, count in counts.items() if count > 1} + + def _translate_values_table(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("VALUES requires an argument") + target = self._unwrap(call.args[0]) + + table_name = self._table_name_from_expr(target) + if table_name is not None: + self._ensure_table_context(table_name) + return f"SELECT DISTINCT * FROM {self._table_sql(table_name)}" + + if isinstance(target, (self.dax.FunctionCall, self.dax.TableConstructor)): + base_sql = self._translate_table(target) + return f"SELECT DISTINCT * FROM ({base_sql}) AS t" + + with self._allow_cross_table_context(): + fragment = self._translate_scalar(target) + + referenced_tables: dict[str, str] = {} + for column in fragment.columns: + if not column.table: + continue + key = column.table.lower() + referenced_tables.setdefault(key, column.table) + + tables: list[str] = [] + if self._base_table and self._base_table.lower() in referenced_tables: + tables.append(referenced_tables.pop(self._base_table.lower())) + for _, table_name in sorted(referenced_tables.items(), key=lambda item: item[0]): + tables.append(table_name) + + if tables: + if len(tables) == 1: + table_sql = self._table_sql(tables[0]) + else: + table_sql = self._build_from_clause_for_tables(tables) + else: + table_sql = self._default_table_sql() + + return f"SELECT DISTINCT {fragment.sql} FROM {table_sql}" + + def _translate_filters_table(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("FILTERS requires an argument") + values_call = self.dax.FunctionCall(name="VALUES", args=[call.args[0]]) + return self._translate_values_table(values_call) + + def _translate_all_like_table(self, call: Any) -> str: + name = call.name.lower() + if name == "allexcept": + if not call.args: + raise DaxTranslationError("ALLEXCEPT requires at least a table argument") + target = self._unwrap(call.args[0]) + table_name = self._table_name_from_expr(target) + if table_name is None: + raise DaxTranslationError("ALLEXCEPT first argument must be a table reference") + self._ensure_table_context(table_name) + return f"SELECT * FROM {self._table_sql(table_name)}" + + if not call.args: + table_name = self.model_name or self._base_table + if table_name is None: + raise DaxTranslationError(f"{call.name} requires a table argument without a table context") + self._ensure_table_context(table_name) + return f"SELECT * FROM {self._table_sql(table_name)}" + + target = self._unwrap(call.args[0]) + table_name = self._table_name_from_expr(target) + if table_name is not None: + self._ensure_table_context(table_name) + return f"SELECT * FROM {self._table_sql(table_name)}" + + if isinstance(target, (self.dax.FunctionCall, self.dax.TableConstructor)): + base_sql = self._translate_table(target) + return f"SELECT DISTINCT * FROM ({base_sql}) AS t" + + values_call = self.dax.FunctionCall(name="VALUES", args=[target]) + return self._translate_values_table(values_call) + + def _translate_date_boundary_table(self, call: Any) -> str: + fragment = self._translate_function_scalar(call) + tables_in_order: list[str] = [] + seen_tables: set[str] = set() + self._append_tables(tables_in_order, seen_tables, fragment.columns) + from_clause = ( + self._build_from_clause_for_tables(tables_in_order) if tables_in_order else self._default_table_sql() + ) + return f"SELECT {fragment.sql} AS value1 FROM {from_clause}" + + def _translate_distinct_table(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("DISTINCT requires an argument") + target = self._unwrap(call.args[0]) + if isinstance( + target, (self.dax.Identifier, self.dax.TableRef, self.dax.FunctionCall, self.dax.TableConstructor) + ): + base_sql = self._translate_table(target) + return f"SELECT DISTINCT * FROM ({base_sql}) AS t" + return self._translate_values_table(call) + + def _translate_renamecolumns_table(self, call: Any) -> str: + if len(call.args) < 3: + raise DaxTranslationError("RENAMECOLUMNS requires a table expression and at least one old/new column pair") + + rename_args = call.args[1:] + if len(rename_args) % 2 != 0: + raise DaxTranslationError("RENAMECOLUMNS requires old/new column argument pairs") + + base_expr = call.args[0] + base_sql = self._translate_table(base_expr) + available_columns = self._infer_table_expr_output_columns(base_expr, base_sql) + input_column_counts = self._infer_table_expr_output_column_counts(base_expr, base_sql) + ambiguous_input_columns = {key for key, count in input_column_counts.items() if count > 1} + input_tables = self._known_table_references(base_expr) + available_lookup = {name.lower(): name for name in available_columns} + rename_parts: list[str] = [] + seen_sources: set[str] = set() + seen_targets: set[str] = set() + for idx in range(0, len(rename_args), 2): + source_spec = self._table_column_arg_spec(rename_args[idx], function_name="RENAMECOLUMNS") + source_name = source_spec.name + target_name = self._table_column_arg_name(rename_args[idx + 1], function_name="RENAMECOLUMNS") + source_key = source_name.lower() + target_key = target_name.lower() + self._validate_table_qualified_column_arg( + source_spec, + function_name="RENAMECOLUMNS", + input_tables=input_tables, + ) + if source_key in ambiguous_input_columns: + raise DaxTranslationError( + f"RENAMECOLUMNS source column '{source_name}' is ambiguous in input table expression" + ) + if available_lookup and source_key not in available_lookup: + raise DaxTranslationError( + f"RENAMECOLUMNS source column '{source_name}' is not present in input table expression" + ) + if source_key in seen_sources: + raise DaxTranslationError("RENAMECOLUMNS source columns must be unique") + if target_key in seen_targets: + raise DaxTranslationError("RENAMECOLUMNS target column names must be unique") + seen_sources.add(source_key) + seen_targets.add(target_key) + resolved_source = available_lookup.get(source_key, source_name) + rename_parts.append(f"{self._quote_identifier(resolved_source)} AS {self._quote_identifier(target_name)}") + + if not rename_parts: + raise DaxTranslationError("RENAMECOLUMNS requires at least one valid old/new column pair") + + return f"SELECT * RENAME ({', '.join(rename_parts)}) FROM ({base_sql}) AS t" + + def _translate_keepcolumns_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("KEEPCOLUMNS requires a table expression and at least one column argument") + + base_expr = call.args[0] + base_sql = self._translate_table(base_expr) + available_columns = self._infer_table_expr_output_columns(base_expr, base_sql) + input_column_counts = self._infer_table_expr_output_column_counts(base_expr, base_sql) + ambiguous_input_columns = {key for key, count in input_column_counts.items() if count > 1} + input_tables = self._known_table_references(base_expr) + available_lookup = {name.lower(): name for name in available_columns} + keep_names: list[str] = [] + seen: set[str] = set() + for raw_arg in call.args[1:]: + spec = self._table_column_arg_spec(raw_arg, function_name="KEEPCOLUMNS") + column_name = spec.name + key = column_name.lower() + self._validate_table_qualified_column_arg(spec, function_name="KEEPCOLUMNS", input_tables=input_tables) + if key in ambiguous_input_columns: + raise DaxTranslationError(f"KEEPCOLUMNS column '{column_name}' is ambiguous in input table expression") + if available_lookup and key not in available_lookup: + raise DaxTranslationError( + f"KEEPCOLUMNS column '{column_name}' is not present in input table expression" + ) + if key in seen: + continue + seen.add(key) + keep_names.append(available_lookup.get(key, column_name)) + + if not keep_names: + raise DaxTranslationError("KEEPCOLUMNS requires at least one valid column argument") + + projections = ", ".join(f"t.{self._quote_identifier(name)}" for name in keep_names) + return f"SELECT {projections} FROM ({base_sql}) AS t" + + def _translate_removecolumns_table(self, call: Any) -> str: + if len(call.args) < 2: + raise DaxTranslationError("REMOVECOLUMNS requires a table expression and at least one column argument") + + base_expr = call.args[0] + base_sql = self._translate_table(base_expr) + available_columns = self._infer_table_expr_output_columns(base_expr, base_sql) + input_column_counts = self._infer_table_expr_output_column_counts(base_expr, base_sql) + ambiguous_input_columns = {key for key, count in input_column_counts.items() if count > 1} + input_tables = self._known_table_references(base_expr) + available_lookup = {name.lower(): name for name in available_columns} + exclude_names: list[str] = [] + seen: set[str] = set() + for raw_arg in call.args[1:]: + spec = self._table_column_arg_spec(raw_arg, function_name="REMOVECOLUMNS") + column_name = spec.name + key = column_name.lower() + self._validate_table_qualified_column_arg(spec, function_name="REMOVECOLUMNS", input_tables=input_tables) + if key in ambiguous_input_columns: + raise DaxTranslationError( + f"REMOVECOLUMNS column '{column_name}' is ambiguous in input table expression" + ) + if available_lookup and key not in available_lookup: + raise DaxTranslationError( + f"REMOVECOLUMNS column '{column_name}' is not present in input table expression" + ) + if key in seen: + continue + seen.add(key) + exclude_names.append(available_lookup.get(key, column_name)) + + if not exclude_names: + raise DaxTranslationError("REMOVECOLUMNS requires at least one valid column argument") + + excluded = ", ".join(self._quote_identifier(name) for name in exclude_names) + return f"SELECT * EXCLUDE ({excluded}) FROM ({base_sql}) AS t" + + def _table_column_arg_spec(self, expr: Any, *, function_name: str) -> TableColumnArg: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.TableColumnRef): + return TableColumnArg(name=expr.column, table=expr.table.name) + if isinstance(expr, self.dax.HierarchyRef): + return TableColumnArg( + name=expr.levels[-1] if expr.levels else expr.column, + table=expr.table.name, + ) + if isinstance(expr, self.dax.String): + return TableColumnArg(name=expr.value) + if isinstance(expr, self.dax.Identifier): + return TableColumnArg(name=expr.name) + if isinstance(expr, self.dax.BracketRef): + return TableColumnArg(name=expr.name) + raise DaxTranslationError(f"{function_name} column arguments must be column references or names") + + def _table_column_arg_name(self, expr: Any, *, function_name: str) -> str: + return self._table_column_arg_spec(expr, function_name=function_name).name + + def _validate_table_qualified_column_arg( + self, + arg: TableColumnArg, + *, + function_name: str, + input_tables: set[str], + ) -> None: + if arg.table is None: + return + + table_name = self._resolve_known_table_name(arg.table) + if table_name is None: + raise DaxTranslationError(f"{function_name} column '{arg.name}' references unknown table '{arg.table}'") + if table_name not in input_tables: + raise DaxTranslationError( + f"{function_name} column '{arg.name}' references table '{arg.table}' not present in input table expression" + ) + if not self._table_has_known_column(table_name, arg.name): + raise DaxTranslationError( + f"{function_name} column '{arg.name}' is not present on referenced table '{arg.table}'" + ) + + def _translate_calculatetable(self, call: Any) -> str: + if not call.args: + raise DaxTranslationError("CALCULATETABLE requires a table expression") + from_sql, wrapped, inherited_filters = self._flatten_calculatetable_source(call.args[0]) + if wrapped: + with self._prefer_unqualified_base_table_context(): + ( + new_filters, + removals, + retentions, + remove_all, + clear_non_keep, + _overrides, + ) = self._translate_filter_args(call.args[1:]) + else: + ( + new_filters, + removals, + retentions, + remove_all, + clear_non_keep, + _overrides, + ) = self._translate_filter_args(call.args[1:]) + inherited = [self._filter_clause_from_sql(sql, keep=True) for sql in inherited_filters] + combined = self._merge_filter_clauses(inherited, new_filters) + combined = self._apply_non_keep_clear(combined, remove_all, clear_non_keep) + retained = self._apply_filter_retentions(combined, retentions, remove_all) + predicates = self._apply_filter_removals(retained, removals, remove_all) + select_sql = "*" + if wrapped and self._base_table: + tables_in_order = [self._base_table] + seen_tables = {self._base_table.lower()} + for predicate in predicates: + if self._is_opaque_filter_predicate(predicate): + continue + self._append_tables(tables_in_order, seen_tables, self._columns_from_sql(predicate)) + if len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) + select_sql = "t.*" + elif not wrapped: + base_table_name = self._base_table or self.model_name + if base_table_name: + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + for predicate in predicates: + if self._is_opaque_filter_predicate(predicate): + continue + self._append_tables(tables_in_order, seen_tables, self._columns_from_sql(predicate)) + if len(tables_in_order) > 1: + from_sql = self._build_from_clause_for_tables(tables_in_order) + select_sql = f"{self._table_sql(base_table_name)}.*" + if not predicates: + return f"SELECT {select_sql} FROM {from_sql}" + return f"SELECT {select_sql} FROM {from_sql} WHERE {' AND '.join(predicates)}" + + def _flatten_calculatetable_source(self, expr: Any) -> tuple[str, bool, list[str]]: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "calculatetable": + if not expr.args: + return self._default_table_sql(), False, [] + + from_sql, wrapped, inherited_filters = self._flatten_calculatetable_source(expr.args[0]) + if wrapped: + with self._prefer_unqualified_base_table_context(): + ( + new_filters, + removals, + retentions, + remove_all, + clear_non_keep, + _overrides, + ) = self._translate_filter_args(expr.args[1:]) + else: + ( + new_filters, + removals, + retentions, + remove_all, + clear_non_keep, + _overrides, + ) = self._translate_filter_args(expr.args[1:]) + + inherited = [self._filter_clause_from_sql(sql, keep=True) for sql in inherited_filters] + combined = self._merge_filter_clauses(inherited, new_filters) + combined = self._apply_non_keep_clear(combined, remove_all, clear_non_keep) + retained = self._apply_filter_retentions(combined, retentions, remove_all) + predicates = self._apply_filter_removals(retained, removals, remove_all) + return from_sql, wrapped, predicates + + from_sql, wrapped = self._table_source_from_expr(expr) + return from_sql, wrapped, [] + + def _table_source_from_expr(self, expr: Any) -> tuple[str, bool]: + expr = self._unwrap(expr) + table_name = self._table_name_from_expr(expr) + if table_name is not None: + self._ensure_table_context(table_name) + return self._table_sql(table_name), False + base_sql = self._translate_table(expr) + return f"({base_sql}) AS t", True + + def _translate_scalar(self, expr: Any) -> _SqlFragment: + expr = self._unwrap(expr) + + if isinstance(expr, self.dax.Number): + return _SqlFragment(expr.value, frozenset()) + if isinstance(expr, self.dax.String): + return _SqlFragment(self._quote_string(expr.value), frozenset()) + if isinstance(expr, self.dax.Boolean): + return _SqlFragment("TRUE" if expr.value else "FALSE", frozenset()) + if isinstance(expr, self.dax.Blank): + return _SqlFragment("NULL", frozenset()) + if isinstance(expr, self.dax.Parameter): + return _SqlFragment(f"@{expr.name}", frozenset()) + if isinstance(expr, self.dax.Identifier): + return self._translate_identifier(expr.name) + if isinstance(expr, self.dax.BracketRef): + return self._translate_identifier(expr.name) + if isinstance(expr, self.dax.TableColumnRef): + return self._translate_table_column(expr.table.name, expr.column) + if isinstance(expr, self.dax.HierarchyRef): + column = expr.levels[-1] if expr.levels else expr.column + return self._translate_table_column(expr.table.name, column) + if isinstance(expr, self.dax.Unary): + inner = self._translate_scalar(expr.expr) + if expr.op == self.dax.UnaryOp.not_: + return inner.wrap(f"NOT {inner.sql}") + if expr.op == self.dax.UnaryOp.minus: + return inner.wrap(f"-{inner.sql}") + if expr.op == self.dax.UnaryOp.plus: + return inner.wrap(f"+{inner.sql}") + if isinstance(expr, self.dax.Binary): + return self._translate_binary(expr) + if isinstance(expr, self.dax.VarBlock): + return self._with_vars(expr, self._translate_scalar, expr.body) + if isinstance(expr, self.dax.Paren): + inner = self._translate_scalar(expr.expr) + return inner.wrap(f"({inner.sql})") + if isinstance(expr, self.dax.TableConstructor): + raise DaxTranslationError("Table constructor not valid in scalar context") + if isinstance(expr, self.dax.FunctionCall): + return self._translate_function_scalar(expr) + + raise DaxTranslationError(f"Unsupported DAX expression type '{type(expr).__name__}'") + + def _translate_binary(self, expr: Any) -> _SqlFragment: + left = self._translate_scalar(expr.left) + right = self._translate_scalar(expr.right) + op = expr.op + + if op == self.dax.BinaryOp.in_: + in_list = self._translate_in_list(expr.right) + sql = f"{left.sql} IN {in_list}" + return _SqlFragment(sql, left.columns | right.columns) + + op_map = { + self.dax.BinaryOp.or_: "OR", + self.dax.BinaryOp.and_: "AND", + self.dax.BinaryOp.eq: "=", + self.dax.BinaryOp.strict_eq: "=", + self.dax.BinaryOp.neq: "<>", + self.dax.BinaryOp.lt: "<", + self.dax.BinaryOp.lte: "<=", + self.dax.BinaryOp.gt: ">", + self.dax.BinaryOp.gte: ">=", + self.dax.BinaryOp.concat: "||", + self.dax.BinaryOp.add: "+", + self.dax.BinaryOp.sub: "-", + self.dax.BinaryOp.mul: "*", + self.dax.BinaryOp.div: "/", + self.dax.BinaryOp.pow: "POWER", + } + + if op == self.dax.BinaryOp.pow: + sql = f"POWER({left.sql}, {right.sql})" + else: + op_sql = op_map.get(op) + if not op_sql: + raise DaxTranslationError("Unsupported binary operator") + sql = f"({left.sql} {op_sql} {right.sql})" + + return _SqlFragment(sql, left.columns | right.columns) + + def _translate_function_scalar(self, call: Any) -> _SqlFragment: + name = call.name.lower() + args = call.args + + if name in ("ignore", "nonvisual"): + if not args: + raise DaxTranslationError(f"{call.name} requires an argument") + if len(args) > 1: + raise DaxTranslationError(f"{call.name} supports exactly one argument") + return self._translate_scalar(args[0]) + if name == "evaluateandlog": + if not args: + raise DaxTranslationError("EVALUATEANDLOG requires an argument") + if len(args) > 1: + raise DaxTranslationError("EVALUATEANDLOG supports exactly one argument") + return self._translate_scalar(args[0]) + if name == "nameof": + return self._translate_nameof(args) + if name == "convert": + return self._translate_convert(args) + if name == "lookupvalue": + return self._translate_lookupvalue(args) + if name == "related": + return self._translate_related(args) + if name == "value": + return self._translate_value(args) + if name == "concatenate": + return self._translate_concatenate(args) + if name == "concatenatex": + return self._translate_concatenatex(args) + if name == "roundup": + return self._translate_roundup(args) + if name == "round": + return self._translate_round(args) + if name == "rounddown": + return self._translate_rounddown(args) + if name == "int": + return self._translate_int(args) + if name == "trunc": + return self._translate_trunc(args) + if name == "mround": + return self._translate_mround(args) + if name == "ceiling": + return self._translate_ceiling(args) + if name == "floor": + return self._translate_floor(args) + if name == "abs": + return self._translate_abs(args) + if name == "mod": + return self._translate_mod(args) + if name == "power": + return self._translate_power(args) + if name == "sqrt": + return self._translate_sqrt(args) + if name == "exp": + return self._translate_exp(args) + if name == "ln": + return self._translate_ln(args) + if name == "log10": + return self._translate_log10(args) + if name == "log": + return self._translate_log(args) + if name == "pi": + return self._translate_pi(args) + if name == "blank": + if args: + raise DaxTranslationError("BLANK does not take arguments") + return _SqlFragment("NULL", frozenset()) + if name == "true": + if args: + raise DaxTranslationError("TRUE does not take arguments") + return _SqlFragment("TRUE", frozenset()) + if name == "false": + if args: + raise DaxTranslationError("FALSE does not take arguments") + return _SqlFragment("FALSE", frozenset()) + if name == "if": + return self._translate_if(args) + if name == "switch": + return self._translate_switch(args) + if name == "selectedvalue": + return self._translate_selectedvalue(args) + if name in ("hasonevalue", "hasonefilter"): + return self._translate_hasone(args) + if name in ("firstnonblank", "firstnonblankvalue"): + return self._translate_first_last_nonblank(args, pick="first") + if name in ("lastnonblank", "lastnonblankvalue"): + return self._translate_first_last_nonblank(args, pick="last") + if name in ("firstdate", "lastdate"): + return self._translate_first_last_date(args, pick="first" if name == "firstdate" else "last") + if name in ("startofmonth", "startofquarter", "startofyear"): + grain = {"startofmonth": "month", "startofquarter": "quarter", "startofyear": "year"}[name] + return self._translate_period_boundary_date(args, grain=grain, end=False) + if name in ("endofmonth", "endofquarter", "endofyear"): + grain = {"endofmonth": "month", "endofquarter": "quarter", "endofyear": "year"}[name] + return self._translate_period_boundary_date(args, grain=grain, end=True) + if name == "date": + return self._translate_date_ctor(args) + if name == "time": + return self._translate_time_ctor(args) + if name == "datevalue": + return self._translate_datevalue(args) + if name == "timevalue": + return self._translate_timevalue(args) + if name == "edate": + return self._translate_edate(args) + if name == "eomonth": + return self._translate_eomonth(args) + if name == "datediff": + return self._translate_datediff(args) + if name == "weekday": + return self._translate_weekday(args) + if name == "weeknum": + return self._translate_weeknum(args) + if name == "containsstring": + return self._translate_containsstring(args, exact=False) + if name == "containsstringexact": + return self._translate_containsstring(args, exact=True) + if name == "containsrow": + return self._translate_containsrow(args) + if name == "upper": + if not args: + raise DaxTranslationError("UPPER requires an argument") + if len(args) > 1: + raise DaxTranslationError("UPPER supports exactly one argument") + target = self._translate_scalar(args[0]) + return _SqlFragment(f"UPPER({target.sql})", target.columns) + if name == "lower": + if not args: + raise DaxTranslationError("LOWER requires an argument") + if len(args) > 1: + raise DaxTranslationError("LOWER supports exactly one argument") + target = self._translate_scalar(args[0]) + return _SqlFragment(f"LOWER({target.sql})", target.columns) + if name == "len": + return self._translate_len(args) + if name == "replace": + return self._translate_replace(args) + if name == "substitute": + return self._translate_substitute(args) + if name == "rept": + return self._translate_rept(args) + if name == "trim": + return self._translate_trim(args) + if name == "left": + return self._translate_left(args) + if name == "right": + return self._translate_right(args) + if name == "mid": + return self._translate_mid(args) + if name == "search": + return self._translate_find_search(args, case_sensitive=False, func="SEARCH") + if name == "find": + return self._translate_find_search(args, case_sensitive=True, func="FIND") + if name == "exact": + return self._translate_exact(args) + if name == "today": + if args: + raise DaxTranslationError("TODAY does not take arguments") + return _SqlFragment("CURRENT_DATE", frozenset()) + if name == "now": + if args: + raise DaxTranslationError("NOW does not take arguments") + return _SqlFragment("CURRENT_TIMESTAMP", frozenset()) + if name == "utcnow": + if args: + raise DaxTranslationError("UTCNOW does not take arguments") + return _SqlFragment("CURRENT_TIMESTAMP", frozenset()) + if name == "utctoday": + if args: + raise DaxTranslationError("UTCTODAY does not take arguments") + return _SqlFragment("CURRENT_DATE", frozenset()) + if name in ("year", "month", "day", "hour", "minute", "second", "quarter"): + return self._translate_date_part(args, part=name) + if name == "rand": + if args: + raise DaxTranslationError("RAND does not take arguments") + return _SqlFragment("RANDOM()", frozenset()) + if name == "randbetween": + return self._translate_randbetween(args) + if name == "format": + return self._translate_format(args) + if name == "iferror": + return self._translate_iferror(args) + if name == "isinscope": + return self._translate_isinscope(args) + if name in ("isfiltered", "iscrossfiltered"): + return self._translate_isfiltered(args) + if name == "coalesce": + return self._translate_coalesce(args) + if name == "divide": + return self._translate_divide(args) + if name == "and": + return self._translate_and_or(args, op="AND") + if name == "or": + return self._translate_and_or(args, op="OR") + if name == "not": + if not args: + raise DaxTranslationError("NOT requires an argument") + if len(args) > 1: + raise DaxTranslationError("NOT supports exactly one argument") + inner = self._translate_scalar(args[0]) + return inner.wrap(f"NOT {inner.sql}") + if name == "isblank": + if not args: + raise DaxTranslationError("ISBLANK requires an argument") + if len(args) > 1: + raise DaxTranslationError("ISBLANK supports exactly one argument") + inner = self._translate_scalar(args[0]) + return inner.wrap(f"{inner.sql} IS NULL") + if name == "isempty": + return self._translate_isempty(args) + if name == "calculate": + with self._allow_cross_table_context(): + metric = self._translate_calculate(call) + return self._metric_to_scalar_fragment(metric) + if name in ("min", "max"): + return self._translate_min_max(call) + if name in ( + "sumx", + "averagex", + "avgx", + "minx", + "maxx", + "medianx", + "countx", + "countax", + "totalytd", + "totalmtd", + "totalqtd", + "totalwtd", + ): + with self._allow_cross_table_context(): + metric = self.translate_metric(call) + return self._metric_to_scalar_fragment(metric) + if name in ( + "sum", + "average", + "averagea", + "avg", + "min", + "mina", + "max", + "maxa", + "median", + "count", + "countrows", + "counta", + "countblank", + "distinctcount", + "distinctcountnoblank", + "approximatedistinctcount", + ): + return self._translate_inline_aggregate(call) + if name in ("selectedmeasure", "selectedmeasurename", "selectedmeasureformatstring", "isselectedmeasure"): + raise DaxTranslationError(f"{call.name} is only supported in calculation group expressions") + + if self._is_table_function_name(name): + raise DaxTranslationError(f"{call.name} returns a table and is not valid in scalar context") + if name in ("userelationship", "crossfilter"): + raise DaxTranslationError(f"{call.name} is only valid in CALCULATE filter arguments") + + raise DaxTranslationError(f"Unsupported scalar function '{call.name}'") + + def _is_table_function_name(self, name: str) -> bool: + return name in { + "filter", + "row", + "selectcolumns", + "addcolumns", + "summarizecolumns", + "summarize", + "groupby", + "topn", + "topnperlevel", + "union", + "crossjoin", + "naturalinnerjoin", + "naturalleftouterjoin", + "intersect", + "except", + "topnskip", + "calendar", + "generateseries", + "datatable", + "relatedtable", + "calculatetable", + "addmissingitems", + "treatas", + "datesbetween", + "datesinperiod", + "datesytd", + "datesmtd", + "datesqtd", + "dateswtd", + "dateadd", + "parallelperiod", + "sameperiodlastyear", + "previousday", + "previousweek", + "previousmonth", + "previousquarter", + "previousyear", + "nextday", + "nextweek", + "nextmonth", + "nextquarter", + "nextyear", + "rollup", + "rollupgroup", + "rollupaddissubtotal", + "rollupissubtotal", + "values", + "filters", + "distinct", + "renamecolumns", + "keepcolumns", + "removecolumns", + "substitutewithindex", + "detailrows", + "all", + "allnoblankrow", + "allselected", + "allcrossfiltered", + "removefilters", + "allexcept", + "generate", + "generateall", + "currentgroup", + } + + def _translate_inline_aggregate(self, call: Any) -> _SqlFragment: + name = call.name.lower() + agg_map = { + "sum": "SUM", + "average": "AVG", + "averagea": "AVG", + "avg": "AVG", + "min": "MIN", + "mina": "MIN", + "max": "MAX", + "maxa": "MAX", + "median": "MEDIAN", + "count": "COUNT", + "countrows": "COUNT", + "counta": "COUNT", + "countblank": "COUNT", + "distinctcount": "COUNT", + "distinctcountnoblank": "COUNT", + "approximatedistinctcount": "COUNT", + } + func = agg_map[name] + if name == "countrows": + if len(call.args) > 1: + raise DaxTranslationError("COUNTROWS supports at most one argument") + if call.args: + target = self._unwrap(call.args[0]) + if isinstance(target, self.dax.FunctionCall) and target.name.lower() == "currentgroup": + return _SqlFragment("COUNT(*)", frozenset()) + distinct_translation = self._translate_countrows_distinct_table(call.args[0]) + if distinct_translation is not None and distinct_translation.sql: + columns = set(self._columns_from_sql(distinct_translation.sql)) + return _SqlFragment(f"COUNT(DISTINCT {distinct_translation.sql})", frozenset(columns)) + target = self._unwrap(call.args[0]) + table_name = self._table_name_from_expr(target) + if table_name is not None: + self._ensure_table_context(table_name) + default_table = self.model_name or self._base_table + if default_table and table_name.lower() == default_table.lower(): + return _SqlFragment("COUNT(*)", frozenset()) + grouped_count = self._translate_grouped_countrows_for_table(table_name) + if grouped_count is not None: + return grouped_count + filters, _overrides = self._filters_from_table(call.args[0]) + distinct_table = self._countrows_distinct_table_name(call.args[0]) + if distinct_table is not None: + grouped_distinct = self._translate_grouped_countrows_distinct_for_table( + distinct_table, filters=filters + ) + if grouped_distinct is not None: + return grouped_distinct + if filters: + sql = self._render_aggregate_sql("count", None, filters) + columns = set() + for clause in filters: + columns.update(self._columns_from_sql(clause)) + return _SqlFragment(sql, frozenset(columns)) + base_table = self._countrows_base_table_name(call.args[0]) + if base_table is not None: + grouped_count = self._translate_grouped_countrows_for_table(base_table) + if grouped_count is not None: + return grouped_count + from_sql, _wrapped = self._table_source_from_expr(call.args[0]) + return _SqlFragment(f"(SELECT COUNT(*) FROM {from_sql})", frozenset()) + return _SqlFragment("COUNT(*)", frozenset()) + if not call.args: + raise DaxTranslationError(f"{call.name} requires an argument") + if len(call.args) > 1: + raise DaxTranslationError(f"{call.name} supports exactly one argument") + arg = self._translate_scalar(call.args[0]) + if name == "countblank": + return _SqlFragment(f"COUNT(CASE WHEN {arg.sql} IS NULL THEN 1 END)", arg.columns) + if name == "distinctcount": + return _SqlFragment(f"COUNT(DISTINCT {arg.sql})", arg.columns) + if name == "distinctcountnoblank": + return _SqlFragment(f"COUNT(DISTINCT {arg.sql})", arg.columns) + if name == "approximatedistinctcount": + return _SqlFragment(f"COUNT(DISTINCT {arg.sql})", arg.columns) + return _SqlFragment(f"{func}({arg.sql})", arg.columns) + + def _translate_min_max(self, call: Any) -> _SqlFragment: + name = call.name.lower() + if not call.args: + raise DaxTranslationError(f"{call.name} requires an argument") + if len(call.args) == 1: + return self._translate_inline_aggregate(call) + if len(call.args) == 2: + left = self._translate_scalar(call.args[0]) + right = self._translate_scalar(call.args[1]) + func = "LEAST" if name == "min" else "GREATEST" + return _SqlFragment(f"{func}({left.sql}, {right.sql})", left.columns | right.columns) + raise DaxTranslationError(f"{call.name} supports one aggregate argument or two scalar arguments") + + def _translate_grouped_countrows_for_table(self, table_name: str) -> _SqlFragment | None: + if not self._current_group_by_columns: + return None + + group_tables = {col.table for col in self._current_group_by_columns if col.table} + if not group_tables: + return None + + count_column = self._grouped_countrows_column_from_relationship_path(table_name, group_tables) + if count_column is None: + table_key = table_name.lower() + has_relationship_path = any( + group_table.lower() == table_key or self._find_relationship_path(group_table, table_name) is not None + for group_table in group_tables + ) + if not has_relationship_path: + return None + count_column = self._representative_table_column(table_name) + if count_column is None: + return None + + count_sql = self._column_sql(table_name, count_column) + return _SqlFragment(f"COUNT({count_sql})", frozenset({ColumnRef(table_name, count_column)})) + + def _grouped_countrows_column_from_relationship_path(self, table_name: str, group_tables: set[str]) -> str | None: + table_key = table_name.lower() + best_path: list[tuple[str, str, str, str]] | None = None + for group_table in group_tables: + if group_table.lower() == table_key: + return self._representative_table_column(table_name) + path = self._find_relationship_path(group_table, table_name) + if path is None: + continue + if best_path is None or len(path) < len(best_path): + best_path = path + if not best_path: + return None + _from_table, to_table, _from_col, to_col = best_path[-1] + if to_table.lower() != table_key: + return None + return to_col + + def _representative_table_column(self, table_name: str) -> str | None: + table_key = table_name.lower() + for candidate_table, column_map in self.column_sql_by_table.items(): + if candidate_table.lower() != table_key: + continue + if column_map: + return next(iter(column_map)) + break + for edge in self._relationship_edges: + if edge.from_table.lower() == table_key: + return edge.from_column + if edge.to_table.lower() == table_key: + return edge.to_column + return None + + def _countrows_base_table_name(self, expr: Any) -> str | None: + expr = self._unwrap(expr) + table_name = self._table_name_from_expr(expr) + if table_name is not None: + return table_name + if isinstance(expr, self.dax.FunctionCall) and expr.args: + name = expr.name.lower() + if name in ("calculatetable", "keepfilters", "nonvisual"): + return self._countrows_base_table_name(expr.args[0]) + return None + + def _translate_grouped_countrows_distinct_for_table( + self, table_name: str, filters: list[str] + ) -> _SqlFragment | None: + count_column = self._grouped_countrows_column_from_relationship_path( + table_name, {col.table for col in self._current_group_by_columns if col.table} + ) + if count_column is None: + return None + count_sql = self._column_sql(table_name, count_column) + sql = self._render_aggregate_sql("count_distinct", count_sql, filters) + columns = {ColumnRef(table_name, count_column)} + for clause in filters: + columns.update(self._columns_from_sql(clause)) + return _SqlFragment(sql, frozenset(columns)) + + def _countrows_distinct_table_name(self, expr: Any) -> str | None: + expr = self._unwrap(expr) + if not isinstance(expr, self.dax.FunctionCall): + return None + name = expr.name.lower() + if name in ("values", "filters", "distinct"): + if not expr.args: + return None + return self._table_name_from_expr(expr.args[0]) + if name in ("calculatetable", "keepfilters", "nonvisual"): + if not expr.args: + return None + return self._countrows_distinct_table_name(expr.args[0]) + return None + + def _metric_to_scalar_fragment(self, metric: MetricTranslation) -> _SqlFragment: + if metric.type in ("time_comparison", "cumulative"): + return self._translate_time_metric_scalar(metric) + + filters = list(metric.filters or []) + if metric.agg: + sql = self._render_aggregate_sql(metric.agg, metric.sql, filters) + else: + if not metric.sql: + raise DaxTranslationError("CALCULATE requires a scalar or aggregate expression") + if filters: + predicate = " AND ".join(filters) + sql = f"CASE WHEN {predicate} THEN {metric.sql} ELSE NULL END" + else: + sql = metric.sql + + columns = set() + if metric.sql: + columns.update(self._columns_from_sql(metric.sql)) + if metric.source_table and not any( + col.table and col.table.lower() == metric.source_table.lower() for col in columns + ): + representative = self._representative_table_column(metric.source_table) + columns.add(ColumnRef(metric.source_table, representative or "")) + for clause in filters: + columns.update(self._columns_from_sql(clause)) + return _SqlFragment(sql, frozenset(columns)) + + def _translate_time_metric_scalar(self, metric: MetricTranslation) -> _SqlFragment: + base_agg, base_sql, base_filters = self._time_metric_base(metric) + base_expr = self._render_aggregate_sql(base_agg, base_sql, base_filters) + + order_col, partition_cols = self._time_window_context(metric) + if order_col is None: + return _SqlFragment(base_expr, frozenset(self._columns_from_sql(base_expr))) + + partition_sql = f"PARTITION BY {', '.join(partition_cols)} " if partition_cols else "" + if metric.type == "cumulative": + if metric.window: + parts = metric.window.split() + if len(parts) == 2: + num, unit = parts + sql = ( + f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} " + f"RANGE BETWEEN INTERVAL '{num} {unit}' PRECEDING AND CURRENT ROW)" + ) + elif len(parts) == 3 and parts[2].lower() == "following": + num, unit = parts[0], parts[1] + sql = ( + f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} " + f"RANGE BETWEEN CURRENT ROW AND INTERVAL '{num} {unit}' FOLLOWING)" + ) + else: + sql = f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" + elif metric.grain_to_date: + grain_partition = f"DATE_TRUNC('{metric.grain_to_date}', {order_col})" + all_parts = [grain_partition, *partition_cols] + sql = ( + f"{base_expr} OVER (PARTITION BY {', '.join(all_parts)} " + f"ORDER BY {order_col} ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" + ) + else: + sql = f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" + return _SqlFragment(sql, frozenset(self._columns_from_sql(sql))) + + lag_offset = self._time_comparison_lag_offset(metric) + if metric.calculation == "previous_value": + sql = f"LAG({base_expr}, {lag_offset}) OVER ({partition_sql}ORDER BY {order_col})" + return _SqlFragment(sql, frozenset(self._columns_from_sql(sql))) + raise DaxTranslationError(f"Unsupported time comparison calculation '{metric.calculation}'") + + def _time_metric_base(self, metric: MetricTranslation) -> tuple[str, str | None, list[str]]: + if metric.type == "time_comparison": + agg = metric.inline_base_agg or self._lookup_measure_agg(metric.base_metric or "") + sql = metric.inline_base_sql + if sql is None and metric.base_metric: + sql = self._lookup_measure_sql(metric.base_metric) + if sql is None and metric.base_metric: + sql = metric.base_metric + filters = list(metric.inline_base_filters or []) + else: + agg = metric.agg + sql = metric.sql + filters = list(metric.filters or []) + + if agg is None: + agg = "sum" + return agg, sql, filters + + def _time_window_context(self, metric: MetricTranslation) -> tuple[str | None, list[str]]: + group_cols = list(self._current_group_by_columns) + if not group_cols: + return None, [] + + grouped_sql: list[str] = [] + time_candidates: list[str] = [] + for col in group_cols: + if not col.table: + continue + col_sql = self._column_sql(col.table, col.column) + grouped_sql.append(col_sql) + known_time = col.column in self.time_dimensions_by_table.get(col.table, set()) + if known_time: + time_candidates.append(col_sql) + + if metric.window_order: + order_sql = metric.window_order + elif time_candidates: + order_sql = time_candidates[0] + elif grouped_sql: + order_sql = grouped_sql[0] + else: + return None, [] + + partition_cols = [sql for sql in grouped_sql if sql != order_sql] + return order_sql, partition_cols + + def _time_comparison_lag_offset(self, metric: MetricTranslation) -> int: + if metric.time_offset: + parts = metric.time_offset.split() + if parts: + try: + return abs(int(parts[0])) + except ValueError: + pass + by_comp = { + "dod": 1, + "wow": 1, + "mom": 1, + "qoq": 1, + "yoy": 1, + "prior_period": 1, + } + return by_comp.get(metric.comparison_type or "", 1) + + def _render_aggregate_sql(self, agg: str, sql: str | None, filters: list[str]) -> str: + predicate = " AND ".join(filters) if filters else None + if agg == "count": + if sql is None: + if predicate: + return f"COUNT(CASE WHEN {predicate} THEN 1 END)" + return "COUNT(*)" + if predicate: + return f"COUNT(CASE WHEN {predicate} THEN {sql} END)" + return f"COUNT({sql})" + + if sql is None: + raise DaxTranslationError(f"{agg} aggregation requires a SQL expression") + + if agg == "count_distinct": + if predicate: + return f"COUNT(DISTINCT CASE WHEN {predicate} THEN {sql} END)" + return f"COUNT(DISTINCT {sql})" + + func_map = {"sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX", "median": "MEDIAN"} + func = func_map.get(agg) + if func is None: + raise DaxTranslationError(f"Unsupported aggregate '{agg}' in scalar context") + if predicate: + return f"{func}(CASE WHEN {predicate} THEN {sql} ELSE NULL END)" + return f"{func}({sql})" + + def _translate_nameof(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("NAMEOF requires an argument") + if len(args) > 1: + raise DaxTranslationError("NAMEOF supports exactly one argument") + target = self._unwrap(args[0]) + if isinstance(target, self.dax.TableColumnRef): + return _SqlFragment(self._quote_string(f"{target.table.name}[{target.column}]"), frozenset()) + if isinstance(target, self.dax.HierarchyRef): + col = target.levels[-1] if target.levels else target.column + return _SqlFragment(self._quote_string(f"{target.table.name}[{col}]"), frozenset()) + if isinstance(target, self.dax.BracketRef): + return _SqlFragment(self._quote_string(target.name), frozenset()) + if isinstance(target, self.dax.Identifier): + return _SqlFragment(self._quote_string(target.name), frozenset()) + resolved = self._translate_scalar(args[0]) + return _SqlFragment(self._quote_string(resolved.sql), resolved.columns) + + def _translate_convert(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("CONVERT requires value and datatype arguments") + if len(args) > 2: + raise DaxTranslationError("CONVERT supports exactly value and datatype arguments") + value = self._translate_scalar(args[0]) + dtype = self._identifier_literal_value(args[1]) + if dtype is None: + return value + normalized = dtype.strip().lower() + cast_type = { + "integer": "BIGINT", + "int64": "BIGINT", + "double": "DOUBLE", + "decimal": "DECIMAL", + "currency": "DECIMAL", + "string": "VARCHAR", + "boolean": "BOOLEAN", + "datetime": "TIMESTAMP", + "date": "DATE", + "time": "TIME", + }.get(normalized) + if cast_type is None: + return value + return _SqlFragment(f"CAST({value.sql} AS {cast_type})", value.columns) + + def _translate_lookupvalue(self, args: list[Any]) -> _SqlFragment: + if len(args) < 3: + raise DaxTranslationError("LOOKUPVALUE requires result column and at least one search pair") + + result_expr = self._unwrap(args[0]) + if isinstance(result_expr, self.dax.TableColumnRef): + result_table = result_expr.table.name + elif isinstance(result_expr, self.dax.HierarchyRef): + result_table = result_expr.table.name + else: + raise DaxTranslationError("LOOKUPVALUE result argument must be a table column reference") + + with self._allow_cross_table_context(): + result_col = self._translate_scalar(args[0]) + + search_args = list(args[1:]) + alternate: _SqlFragment | None = None + if len(search_args) % 2 == 1: + alternate = self._translate_scalar(search_args[-1]) + search_args = search_args[:-1] + + if len(search_args) < 2 or len(search_args) % 2 != 0: + raise DaxTranslationError("LOOKUPVALUE requires search column/value pairs") + + predicates: list[str] = [] + outer_columns: set[ColumnRef] = set() + for idx in range(0, len(search_args), 2): + search_col = self._translate_scalar(search_args[idx]) + search_val = self._translate_scalar(search_args[idx + 1]) + predicates.append(f"{search_col.sql} = {search_val.sql}") + outer_columns.update(search_val.columns) + + table_sql = self._table_sql(result_table) + where_sql = f" WHERE {' AND '.join(predicates)}" if predicates else "" + subquery = f"(SELECT {result_col.sql} FROM {table_sql}{where_sql} LIMIT 1)" + if alternate is None: + return _SqlFragment(subquery, frozenset(outer_columns)) + outer_columns.update(alternate.columns) + return _SqlFragment(f"COALESCE({subquery}, {alternate.sql})", frozenset(outer_columns)) + + def _translate_related(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("RELATED requires an argument") + if len(args) > 1: + raise DaxTranslationError("RELATED supports exactly one argument") + with self._allow_cross_table_context(): + return self._translate_scalar(args[0]) + + def _translate_value(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("VALUE requires an argument") + if len(args) > 1: + raise DaxTranslationError("VALUE supports exactly one argument") + target = self._translate_scalar(args[0]) + return _SqlFragment(f"CAST({target.sql} AS DOUBLE)", target.columns) + + def _translate_concatenate(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("CONCATENATE requires two arguments") + if len(args) > 2: + raise DaxTranslationError("CONCATENATE supports exactly two arguments") + left = self._translate_scalar(args[0]) + right = self._translate_scalar(args[1]) + return _SqlFragment(f"({left.sql} || {right.sql})", left.columns | right.columns) + + def _translate_concatenatex(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("CONCATENATEX requires table and expression arguments") + + table_expr = args[0] + value_expr = args[1] + delimiter_expr = args[2] if len(args) > 2 else None + order_args = args[3:] if len(args) > 3 else [] + + table_target = self._unwrap(table_expr) + base_table_name = self._table_name_from_expr(table_target) + wrapped = base_table_name is None + from_sql, _ = self._table_source_from_expr(table_expr) + + value_fragment: _SqlFragment + delimiter_fragment: _SqlFragment + order_by_parts: list[str] + order_columns: set[ColumnRef] + qualifier_ctx = self._prefer_unqualified_base_table_context() if wrapped else nullcontext() + with qualifier_ctx: + value_fragment = ( + self._translate_projection_scalar(value_expr) if wrapped else self._translate_scalar(value_expr) + ) + if delimiter_expr is None: + delimiter_fragment = _SqlFragment("''", frozenset()) + else: + delimiter_fragment = ( + self._translate_projection_scalar(delimiter_expr) + if wrapped + else self._translate_scalar(delimiter_expr) + ) + order_by_parts, order_columns = self._parse_order_by_parts_with_columns(order_args, projection_safe=wrapped) + + if base_table_name is not None: + tables_in_order = [base_table_name] + seen_tables = {base_table_name.lower()} + self._append_tables(tables_in_order, seen_tables, value_fragment.columns) + self._append_tables(tables_in_order, seen_tables, delimiter_fragment.columns) + self._append_tables(tables_in_order, seen_tables, order_columns) + from_sql = self._build_from_clause_for_tables(tables_in_order) + else: + value_fragment = value_fragment.wrap(_rewrite_expr_for_alias(value_fragment.sql, "t")) + delimiter_fragment = delimiter_fragment.wrap(_rewrite_expr_for_alias(delimiter_fragment.sql, "t")) + order_by_parts = [_rewrite_expr_for_alias(order_sql, "t") for order_sql in order_by_parts] + + order_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" + aggregate_sql = ( + f"STRING_AGG(CAST({value_fragment.sql} AS VARCHAR), CAST({delimiter_fragment.sql} AS VARCHAR){order_sql})" + ) + columns = value_fragment.columns | delimiter_fragment.columns | frozenset(order_columns) + return _SqlFragment(f"(SELECT {aggregate_sql} FROM {from_sql})", columns) + + def _translate_roundup(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("ROUNDUP requires number and num_digits arguments") + value = self._translate_scalar(args[0]) + digits = self._translate_scalar(args[1]) + sql = ( + f"CASE WHEN {digits.sql} >= 0 " + f"THEN SIGN({value.sql}) * CEIL(ABS({value.sql}) * POWER(10, {digits.sql})) / POWER(10, {digits.sql}) " + f"ELSE SIGN({value.sql}) * CEIL(ABS({value.sql}) / POWER(10, -({digits.sql}))) * POWER(10, -({digits.sql})) END" + ) + return _SqlFragment(sql, value.columns | digits.columns) + + def _translate_round(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("ROUND requires number and num_digits arguments") + value = self._translate_scalar(args[0]) + digits = self._translate_scalar(args[1]) + sql = ( + f"CASE WHEN {digits.sql} >= 0 " + f"THEN SIGN({value.sql}) * FLOOR(ABS({value.sql}) * POWER(10, {digits.sql}) + 0.5) / POWER(10, {digits.sql}) " + f"ELSE SIGN({value.sql}) * FLOOR(ABS({value.sql}) / POWER(10, -({digits.sql})) + 0.5) * POWER(10, -({digits.sql})) END" + ) + return _SqlFragment(sql, value.columns | digits.columns) + + def _translate_rounddown(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("ROUNDDOWN requires number and num_digits arguments") + value = self._translate_scalar(args[0]) + digits = self._translate_scalar(args[1]) + sql = ( + f"CASE WHEN {digits.sql} >= 0 " + f"THEN SIGN({value.sql}) * FLOOR(ABS({value.sql}) * POWER(10, {digits.sql})) / POWER(10, {digits.sql}) " + f"ELSE SIGN({value.sql}) * FLOOR(ABS({value.sql}) / POWER(10, -({digits.sql}))) * POWER(10, -({digits.sql})) END" + ) + return _SqlFragment(sql, value.columns | digits.columns) + + def _translate_int(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("INT requires an argument") + if len(args) > 1: + raise DaxTranslationError("INT supports exactly one argument") + value = self._translate_scalar(args[0]) + return _SqlFragment(f"FLOOR({value.sql})", value.columns) + + def _translate_trunc(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("TRUNC requires a number argument") + if len(args) > 2: + raise DaxTranslationError("TRUNC supports at most number and num_digits arguments") + value = self._translate_scalar(args[0]) + digits = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("0", frozenset()) + sql = ( + f"CASE WHEN {digits.sql} >= 0 " + f"THEN SIGN({value.sql}) * FLOOR(ABS({value.sql}) * POWER(10, {digits.sql})) / POWER(10, {digits.sql}) " + f"ELSE SIGN({value.sql}) * FLOOR(ABS({value.sql}) / POWER(10, -({digits.sql}))) * POWER(10, -({digits.sql})) END" + ) + return _SqlFragment(sql, value.columns | digits.columns) + + def _translate_mround(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("MROUND requires number and multiple arguments") + value = self._translate_scalar(args[0]) + multiple = self._translate_scalar(args[1]) + sql = ( + f"CASE WHEN {multiple.sql} = 0 THEN 0 " + f"ELSE SIGN({value.sql}) * FLOOR((ABS({value.sql}) / ABS({multiple.sql})) + 0.5) * ABS({multiple.sql}) END" + ) + return _SqlFragment(sql, value.columns | multiple.columns) + + def _translate_ceiling(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("CEILING requires at least one argument") + if len(args) > 2: + raise DaxTranslationError("CEILING supports at most number and significance arguments") + value = self._translate_scalar(args[0]) + if len(args) == 1: + return _SqlFragment(f"CEIL({value.sql})", value.columns) + significance = self._translate_scalar(args[1]) + sql = f"(CEIL({value.sql} / {significance.sql}) * {significance.sql})" + return _SqlFragment(sql, value.columns | significance.columns) + + def _translate_floor(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("FLOOR requires at least one argument") + if len(args) > 2: + raise DaxTranslationError("FLOOR supports at most number and significance arguments") + value = self._translate_scalar(args[0]) + if len(args) == 1: + return _SqlFragment(f"FLOOR({value.sql})", value.columns) + significance = self._translate_scalar(args[1]) + sql = f"(FLOOR({value.sql} / {significance.sql}) * {significance.sql})" + return _SqlFragment(sql, value.columns | significance.columns) + + def _translate_abs(self, args: list[Any]) -> _SqlFragment: + if len(args) != 1: + raise DaxTranslationError("ABS requires exactly one argument") + value = self._translate_scalar(args[0]) + return _SqlFragment(f"ABS({value.sql})", value.columns) + + def _translate_mod(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("MOD requires number and divisor arguments") + number = self._translate_scalar(args[0]) + divisor = self._translate_scalar(args[1]) + return _SqlFragment(f"MOD({number.sql}, {divisor.sql})", number.columns | divisor.columns) + + def _translate_power(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("POWER requires number and exponent arguments") + number = self._translate_scalar(args[0]) + exponent = self._translate_scalar(args[1]) + return _SqlFragment(f"POWER({number.sql}, {exponent.sql})", number.columns | exponent.columns) + + def _translate_sqrt(self, args: list[Any]) -> _SqlFragment: + if len(args) != 1: + raise DaxTranslationError("SQRT requires exactly one argument") + value = self._translate_scalar(args[0]) + return _SqlFragment(f"SQRT({value.sql})", value.columns) + + def _translate_exp(self, args: list[Any]) -> _SqlFragment: + if len(args) != 1: + raise DaxTranslationError("EXP requires exactly one argument") + value = self._translate_scalar(args[0]) + return _SqlFragment(f"EXP({value.sql})", value.columns) + + def _translate_ln(self, args: list[Any]) -> _SqlFragment: + if len(args) != 1: + raise DaxTranslationError("LN requires exactly one argument") + value = self._translate_scalar(args[0]) + return _SqlFragment(f"LN({value.sql})", value.columns) + + def _translate_log10(self, args: list[Any]) -> _SqlFragment: + if len(args) != 1: + raise DaxTranslationError("LOG10 requires exactly one argument") + value = self._translate_scalar(args[0]) + return _SqlFragment(f"LOG10({value.sql})", value.columns) + + def _translate_log(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("LOG requires at least one argument") + if len(args) > 2: + raise DaxTranslationError("LOG supports at most number and base arguments") + value = self._translate_scalar(args[0]) + if len(args) == 1: + return _SqlFragment(f"LOG10({value.sql})", value.columns) + base = self._translate_scalar(args[1]) + return _SqlFragment(f"(LN({value.sql}) / LN({base.sql}))", value.columns | base.columns) + + def _translate_pi(self, args: list[Any]) -> _SqlFragment: + if args: + raise DaxTranslationError("PI does not take arguments") + return _SqlFragment("PI()", frozenset()) + + def _translate_if(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("IF requires condition") + if len(args) > 3: + raise DaxTranslationError("IF supports at most condition, value_if_true, and value_if_false arguments") + condition = self._translate_scalar(args[0]) + true_expr = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("NULL", frozenset()) + false_expr = self._translate_scalar(args[2]) if len(args) > 2 else _SqlFragment("NULL", frozenset()) + sql = f"CASE WHEN {condition.sql} THEN {true_expr.sql} ELSE {false_expr.sql} END" + columns = condition.columns | true_expr.columns | false_expr.columns + return _SqlFragment(sql, columns) + + def _translate_selectedvalue(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("SELECTEDVALUE requires an argument") + if len(args) > 2: + raise DaxTranslationError("SELECTEDVALUE supports at most column and alternate_result arguments") + target = self._translate_scalar(args[0]) + alternate = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("NULL", frozenset()) + sql = f"CASE WHEN COUNT(DISTINCT {target.sql}) = 1 THEN MIN({target.sql}) ELSE {alternate.sql} END" + return _SqlFragment(sql, target.columns | alternate.columns) + + def _translate_hasone(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("HASONEVALUE requires an argument") + if len(args) > 1: + raise DaxTranslationError("HASONEVALUE/HASONEFILTER supports exactly one argument") + target = self._translate_scalar(args[0]) + return _SqlFragment(f"(COUNT(DISTINCT {target.sql}) = 1)", target.columns) + + def _translate_first_last_nonblank(self, args: list[Any], *, pick: str) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("FIRSTNONBLANK/LASTNONBLANK requires a column and expression") + if len(args) > 2: + raise DaxTranslationError("FIRSTNONBLANK/LASTNONBLANK supports exactly column and expression arguments") + target = self._translate_scalar(args[0]) + predicate = self._translate_scalar(args[1]) + agg = "MIN" if pick == "first" else "MAX" + sql = f"{agg}(CASE WHEN {predicate.sql} IS NOT NULL THEN {target.sql} ELSE NULL END)" + return _SqlFragment(sql, target.columns | predicate.columns) + + def _translate_first_last_date(self, args: list[Any], *, pick: str) -> _SqlFragment: + if not args: + raise DaxTranslationError("FIRSTDATE/LASTDATE requires an argument") + if len(args) > 1: + raise DaxTranslationError("FIRSTDATE/LASTDATE supports exactly one argument") + target = self._translate_scalar(args[0]) + agg = "MIN" if pick == "first" else "MAX" + return _SqlFragment(f"{agg}({target.sql})", target.columns) + + def _translate_period_boundary_date(self, args: list[Any], *, grain: str, end: bool) -> _SqlFragment: + if not args: + raise DaxTranslationError("STARTOF*/ENDOF* requires an argument") + if len(args) > 1: + raise DaxTranslationError("STARTOF*/ENDOF* supports exactly one argument") + target = self._translate_scalar(args[0]) + base = f"DATE_TRUNC('{grain}', {target.sql})" + if not end: + return _SqlFragment(f"MIN({base})", target.columns) + interval = {"month": "1 month", "quarter": "3 month", "year": "1 year"}[grain] + sql = f"MIN({base} + INTERVAL '{interval}' - INTERVAL '1 day')" + return _SqlFragment(sql, target.columns) + + def _translate_date_ctor(self, args: list[Any]) -> _SqlFragment: + if len(args) != 3: + raise DaxTranslationError("DATE requires year, month, and day arguments") + year = self._translate_scalar(args[0]) + month = self._translate_scalar(args[1]) + day = self._translate_scalar(args[2]) + sql = f"MAKE_DATE({year.sql}, {month.sql}, {day.sql})" + return _SqlFragment(sql, year.columns | month.columns | day.columns) + + def _translate_time_ctor(self, args: list[Any]) -> _SqlFragment: + if len(args) != 3: + raise DaxTranslationError("TIME requires hour, minute, and second arguments") + hour = self._translate_scalar(args[0]) + minute = self._translate_scalar(args[1]) + second = self._translate_scalar(args[2]) + sql = f"MAKE_TIME({hour.sql}, {minute.sql}, {second.sql})" + return _SqlFragment(sql, hour.columns | minute.columns | second.columns) + + def _translate_datevalue(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("DATEVALUE requires an argument") + if len(args) > 1: + raise DaxTranslationError("DATEVALUE supports exactly one argument") + target = self._translate_scalar(args[0]) + return _SqlFragment(f"CAST({target.sql} AS DATE)", target.columns) + + def _translate_timevalue(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("TIMEVALUE requires an argument") + if len(args) > 1: + raise DaxTranslationError("TIMEVALUE supports exactly one argument") + target = self._translate_scalar(args[0]) + return _SqlFragment(f"CAST({target.sql} AS TIME)", target.columns) + + def _translate_edate(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("EDATE requires start date and month offset arguments") + start = self._translate_scalar(args[0]) + months = self._translate_scalar(args[1]) + sql = f"(CAST({start.sql} AS DATE) + ({months.sql}) * INTERVAL '1 month')" + return _SqlFragment(sql, start.columns | months.columns) + + def _translate_eomonth(self, args: list[Any]) -> _SqlFragment: + if len(args) != 2: + raise DaxTranslationError("EOMONTH requires start date and month offset arguments") + start = self._translate_scalar(args[0]) + months = self._translate_scalar(args[1]) + shifted = f"(CAST({start.sql} AS DATE) + ({months.sql}) * INTERVAL '1 month')" + sql = f"(DATE_TRUNC('month', {shifted}) + INTERVAL '1 month' - INTERVAL '1 day')" + return _SqlFragment(sql, start.columns | months.columns) + + def _translate_datediff(self, args: list[Any]) -> _SqlFragment: + if len(args) != 3: + raise DaxTranslationError("DATEDIFF requires start date, end date, and interval arguments") + start = self._translate_scalar(args[0]) + end = self._translate_scalar(args[1]) + unit = self._identifier_literal_value(args[2]) + if unit is None: + raise DaxTranslationError("DATEDIFF interval must be an identifier or string") + normalized = unit.lower() + if normalized.endswith("s"): + normalized = normalized[:-1] + sql = f"DATE_DIFF('{normalized}', CAST({start.sql} AS DATE), CAST({end.sql} AS DATE))" + return _SqlFragment(sql, start.columns | end.columns) + + def _translate_weekday(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("WEEKDAY requires a date argument") + if len(args) > 2: + raise DaxTranslationError("WEEKDAY supports at most date and return_type arguments") + date_value = self._translate_scalar(args[0]) + return_type = self._number_literal_value(args[1]) if len(args) > 1 else 1 + dow = f"EXTRACT(DOW FROM CAST({date_value.sql} AS DATE))" + if return_type == 1: + sql = f"({dow} + 1)" + elif return_type == 2: + sql = f"((({dow} + 6) % 7) + 1)" + elif return_type == 3: + sql = f"(({dow} + 6) % 7)" + elif return_type in (11, 12, 13, 14, 15, 16, 17): + start_dow = return_type - 10 + if start_dow == 7: + start_dow = 0 + sql = f"((({dow} - {start_dow} + 7) % 7) + 1)" + else: + raise DaxTranslationError("WEEKDAY return_type currently supports 1, 2, 3, or 11-17") + return _SqlFragment(sql, date_value.columns) + + def _translate_weeknum(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("WEEKNUM requires a date argument") + if len(args) > 2: + raise DaxTranslationError("WEEKNUM supports at most date and return_type arguments") + date_value = self._translate_scalar(args[0]) + return_type = self._number_literal_value(args[1]) if len(args) > 1 else 1 + if return_type == 1: + sql = f"(CAST(STRFTIME(CAST({date_value.sql} AS DATE), '%U') AS INTEGER) + 1)" + elif return_type == 2: + sql = f"(CAST(STRFTIME(CAST({date_value.sql} AS DATE), '%W') AS INTEGER) + 1)" + elif return_type == 21: + sql = f"CAST(STRFTIME(CAST({date_value.sql} AS DATE), '%V') AS INTEGER)" + else: + raise DaxTranslationError("WEEKNUM return_type currently supports 1, 2, or 21") + return _SqlFragment(sql, date_value.columns) + + def _translate_containsstring(self, args: list[Any], *, exact: bool) -> _SqlFragment: + if len(args) < 2: + func = "CONTAINSSTRINGEXACT" if exact else "CONTAINSSTRING" + raise DaxTranslationError(f"{func} requires text and search arguments") + if len(args) > 2: + func = "CONTAINSSTRINGEXACT" if exact else "CONTAINSSTRING" + raise DaxTranslationError(f"{func} supports exactly two arguments") + text = self._translate_scalar(args[0]) + search = self._translate_scalar(args[1]) + if exact: + sql = f"(POSITION({search.sql} IN {text.sql}) > 0)" + else: + sql = f"(POSITION(LOWER({search.sql}) IN LOWER({text.sql})) > 0)" + return _SqlFragment(sql, text.columns | search.columns) + + def _translate_containsrow(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("CONTAINSROW requires a table expression and at least one value argument") + + table_expr = self._unwrap(args[0]) + if self._table_name_from_expr(table_expr) is None and not isinstance( + table_expr, (self.dax.FunctionCall, self.dax.TableConstructor) + ): + raise DaxTranslationError("CONTAINSROW requires a table expression as first argument") + + table_sql = self._translate_table(table_expr) + table_width = self._treatas_source_width(table_expr, table_sql) + if table_width is None: + raise DaxTranslationError("CONTAINSROW requires an inferable table column count") + + value_exprs = args[1:] + if len(value_exprs) != table_width: + raise DaxTranslationError("CONTAINSROW value argument count must match table column count") + + value_fragments = [self._translate_scalar(expr) for expr in value_exprs] + alias_names = [f"c{idx + 1}" for idx in range(table_width)] + alias_list_sql = ", ".join(self._quote_identifier(alias) for alias in alias_names) + predicates = [ + f"t.{self._quote_identifier(alias_name)} IS NOT DISTINCT FROM {value_fragment.sql}" + for alias_name, value_fragment in zip(alias_names, value_fragments, strict=False) + ] + predicate_sql = " AND ".join(predicates) if predicates else "TRUE" + sql = f"EXISTS (SELECT 1 FROM ({table_sql}) AS t({alias_list_sql}) WHERE {predicate_sql})" + + columns = set(self._columns_from_sql(table_sql)) + for value_fragment in value_fragments: + columns.update(value_fragment.columns) + return _SqlFragment(sql, frozenset(columns)) + + def _translate_len(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("LEN requires an argument") + if len(args) > 1: + raise DaxTranslationError("LEN supports exactly one argument") + value = self._translate_scalar(args[0]) + return _SqlFragment(f"LENGTH({value.sql})", value.columns) + + def _translate_replace(self, args: list[Any]) -> _SqlFragment: + if len(args) < 4: + raise DaxTranslationError("REPLACE requires old_text, start_num, num_chars, and new_text arguments") + text = self._translate_scalar(args[0]) + start_num = self._translate_scalar(args[1]) + num_chars = self._translate_scalar(args[2]) + new_text = self._translate_scalar(args[3]) + safe_start = f"GREATEST({start_num.sql}, 1)" + safe_chars = f"GREATEST({num_chars.sql}, 0)" + sql = ( + f"(CASE WHEN {safe_start} <= 1 THEN '' ELSE SUBSTRING({text.sql}, 1, {safe_start} - 1) END " + f"|| {new_text.sql} || SUBSTRING({text.sql}, {safe_start} + {safe_chars}))" + ) + return _SqlFragment(sql, text.columns | start_num.columns | num_chars.columns | new_text.columns) + + def _translate_substitute(self, args: list[Any]) -> _SqlFragment: + if len(args) < 3: + raise DaxTranslationError("SUBSTITUTE requires text, old_text, and new_text arguments") + if len(args) > 4: + raise DaxTranslationError( + "SUBSTITUTE supports at most text, old_text, new_text, and instance_num arguments" + ) + text = self._translate_scalar(args[0]) + old_text = self._translate_scalar(args[1]) + new_text = self._translate_scalar(args[2]) + if len(args) == 3: + sql = f"REPLACE({text.sql}, {old_text.sql}, {new_text.sql})" + return _SqlFragment(sql, text.columns | old_text.columns | new_text.columns) + + instance_num = self._number_literal_value(args[3]) + if instance_num is not None: + if instance_num < 1: + raise DaxTranslationError("SUBSTITUTE instance_num must be >= 1") + + pos_sql = self._nth_occurrence_position_sql(text.sql, old_text.sql, instance_num) + sql = ( + f"CASE WHEN {old_text.sql} = '' THEN {text.sql} " + f"WHEN ({pos_sql}) = 0 THEN {text.sql} " + f"ELSE SUBSTR({text.sql}, 1, ({pos_sql}) - 1) || {new_text.sql} " + f"|| SUBSTR({text.sql}, ({pos_sql}) + LENGTH({old_text.sql})) END" + ) + return _SqlFragment(sql, text.columns | old_text.columns | new_text.columns) + + instance_fragment = self._translate_scalar(args[3]) + instance_sql = f"CAST(({instance_fragment.sql}) AS BIGINT)" + split_sql = f"STRING_SPLIT({text.sql}, {old_text.sql})" + occurrences_sql = f"(ARRAY_LENGTH({split_sql}) - 1)" + prefix_sql = f"ARRAY_TO_STRING(LIST_SLICE({split_sql}, 1, {instance_sql}), {old_text.sql})" + suffix_sql = ( + f"ARRAY_TO_STRING(LIST_SLICE({split_sql}, {instance_sql} + 1, ARRAY_LENGTH({split_sql})), {old_text.sql})" + ) + sql = ( + f"CASE WHEN {old_text.sql} = '' THEN {text.sql} " + f"WHEN {instance_sql} IS NULL OR {instance_sql} < 1 THEN {text.sql} " + f"WHEN {instance_sql} > {occurrences_sql} THEN {text.sql} " + f"ELSE {prefix_sql} || {new_text.sql} || {suffix_sql} END" + ) + return _SqlFragment(sql, text.columns | old_text.columns | new_text.columns | instance_fragment.columns) + + def _nth_occurrence_position_sql(self, text_sql: str, needle_sql: str, occurrence: int) -> str: + if occurrence < 1: + raise DaxTranslationError("SUBSTITUTE instance_num must be >= 1") + pos_sql = f"INSTR({text_sql}, {needle_sql})" + for _ in range(2, occurrence + 1): + next_sql = f"INSTR(SUBSTR({text_sql}, ({pos_sql}) + LENGTH({needle_sql})), {needle_sql})" + pos_sql = ( + f"CASE WHEN ({pos_sql}) = 0 THEN 0 " + f"WHEN ({next_sql}) = 0 THEN 0 " + f"ELSE ({next_sql}) + ({pos_sql}) + LENGTH({needle_sql}) - 1 END" + ) + return pos_sql + + def _translate_rept(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("REPT requires text and number_times arguments") + if len(args) > 2: + raise DaxTranslationError("REPT supports exactly two arguments") + text = self._translate_scalar(args[0]) + number_times = self._translate_scalar(args[1]) + safe_count = f"GREATEST(CAST(FLOOR({number_times.sql}) AS BIGINT), 0)" + return _SqlFragment(f"REPEAT({text.sql}, {safe_count})", text.columns | number_times.columns) + + def _translate_trim(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("TRIM requires an argument") + if len(args) > 1: + raise DaxTranslationError("TRIM supports exactly one argument") + value = self._translate_scalar(args[0]) + sql = f"TRIM(REGEXP_REPLACE(CAST({value.sql} AS VARCHAR), ' +', ' ', 'g'))" + return _SqlFragment(sql, value.columns) + + def _translate_left(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("LEFT requires a text argument") + if len(args) > 2: + raise DaxTranslationError("LEFT supports at most text and num_chars arguments") + text = self._translate_scalar(args[0]) + num_chars = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("1", frozenset()) + sql = f"SUBSTRING({text.sql}, 1, GREATEST({num_chars.sql}, 0))" + return _SqlFragment(sql, text.columns | num_chars.columns) + + def _translate_right(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("RIGHT requires a text argument") + if len(args) > 2: + raise DaxTranslationError("RIGHT supports at most text and num_chars arguments") + text = self._translate_scalar(args[0]) + num_chars = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("1", frozenset()) + sql = ( + f"CASE WHEN {num_chars.sql} <= 0 THEN '' " + f"ELSE SUBSTRING({text.sql}, GREATEST(LENGTH({text.sql}) - {num_chars.sql} + 1, 1), {num_chars.sql}) END" + ) + return _SqlFragment(sql, text.columns | num_chars.columns) + + def _translate_mid(self, args: list[Any]) -> _SqlFragment: + if len(args) != 3: + raise DaxTranslationError("MID requires text, start_num, and num_chars arguments") + text = self._translate_scalar(args[0]) + start_num = self._translate_scalar(args[1]) + num_chars = self._translate_scalar(args[2]) + sql = ( + f"CASE WHEN {num_chars.sql} <= 0 THEN '' " + f"ELSE SUBSTRING({text.sql}, GREATEST({start_num.sql}, 1), {num_chars.sql}) END" + ) + return _SqlFragment(sql, text.columns | start_num.columns | num_chars.columns) + + def _translate_find_search(self, args: list[Any], *, case_sensitive: bool, func: str) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError(f"{func} requires find_text and within_text arguments") + if len(args) > 4: + raise DaxTranslationError(f"{func} supports at most find_text, within_text, start_num, and not_found_value") + needle = self._translate_scalar(args[0]) + haystack = self._translate_scalar(args[1]) + start_num = self._translate_scalar(args[2]) if len(args) > 2 else None + not_found = self._translate_scalar(args[3]) if len(args) > 3 else None + + if case_sensitive: + needle_sql = needle.sql + haystack_sql = haystack.sql + else: + needle_sql = f"LOWER({needle.sql})" + haystack_sql = f"LOWER({haystack.sql})" + + if start_num is None: + base_pos = f"POSITION({needle_sql} IN {haystack_sql})" + adjusted = base_pos + else: + segment = f"SUBSTRING({haystack_sql}, {start_num.sql})" + base_pos = f"POSITION({needle_sql} IN {segment})" + adjusted = f"CASE WHEN {base_pos} = 0 THEN 0 ELSE ({base_pos} + {start_num.sql} - 1) END" + + if not_found is not None: + sql = f"CASE WHEN {adjusted} = 0 THEN {not_found.sql} ELSE {adjusted} END" + else: + sql = adjusted + + columns = set(needle.columns) | set(haystack.columns) + if start_num is not None: + columns.update(start_num.columns) + if not_found is not None: + columns.update(not_found.columns) + return _SqlFragment(sql, frozenset(columns)) + + def _translate_date_part(self, args: list[Any], *, part: str) -> _SqlFragment: + if not args: + raise DaxTranslationError(f"{part.upper()} requires an argument") + if len(args) > 1: + raise DaxTranslationError(f"{part.upper()} supports exactly one argument") + value = self._translate_scalar(args[0]) + part_map = { + "year": "YEAR", + "month": "MONTH", + "day": "DAY", + "hour": "HOUR", + "minute": "MINUTE", + "second": "SECOND", + "quarter": "QUARTER", + } + part_sql = part_map[part] + cast_type = "TIMESTAMP" if part in ("hour", "minute", "second") else "DATE" + sql = f"EXTRACT({part_sql} FROM CAST({value.sql} AS {cast_type}))" + return _SqlFragment(sql, value.columns) + + def _translate_exact(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("EXACT requires two arguments") + if len(args) > 2: + raise DaxTranslationError("EXACT supports exactly two arguments") + left = self._translate_scalar(args[0]) + right = self._translate_scalar(args[1]) + return _SqlFragment(f"({left.sql} = {right.sql})", left.columns | right.columns) + + def _translate_randbetween(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("RANDBETWEEN requires bottom and top arguments") + if len(args) > 2: + raise DaxTranslationError("RANDBETWEEN supports exactly two arguments") + lower = self._translate_scalar(args[0]) + upper = self._translate_scalar(args[1]) + sql = f"CAST(FLOOR(RANDOM() * (({upper.sql}) - ({lower.sql}) + 1) + ({lower.sql})) AS BIGINT)" + return _SqlFragment(sql, lower.columns | upper.columns) + + def _translate_format(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("FORMAT requires value and format_string arguments") + if len(args) > 3: + raise DaxTranslationError("FORMAT supports at most value, format_string, and locale arguments") + value = self._translate_scalar(args[0]) + # Current lowering preserves value-to-text behavior and ignores format mask semantics. + return _SqlFragment(f"CAST({value.sql} AS VARCHAR)", value.columns) + + def _translate_iferror(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("IFERROR requires value and value_if_error arguments") + if len(args) > 2: + raise DaxTranslationError("IFERROR supports exactly two arguments") + value = self._translate_scalar(args[0]) + fallback = self._translate_scalar(args[1]) + sql = f"CASE WHEN {value.sql} IS NULL THEN {fallback.sql} ELSE {value.sql} END" + return _SqlFragment(sql, value.columns | fallback.columns) + + def _translate_isinscope(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("ISINSCOPE requires an argument") + if len(args) > 1: + raise DaxTranslationError("ISINSCOPE supports exactly one argument") + target = self._translate_scalar(args[0]) + in_scope = self._any_column_in_context(target.columns, self._current_group_by_columns) + return _SqlFragment("TRUE" if in_scope else "FALSE", target.columns) + + def _translate_isfiltered(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("ISFILTERED requires an argument") + if len(args) > 1: + raise DaxTranslationError("ISFILTERED/ISCROSSFILTERED supports exactly one argument") + target = self._translate_scalar(args[0]) + is_filtered = self._any_column_in_context(target.columns, self._current_filter_columns) + return _SqlFragment("TRUE" if is_filtered else "FALSE", target.columns) + + def _translate_isempty(self, args: list[Any]) -> _SqlFragment: + if not args: + raise DaxTranslationError("ISEMPTY requires a table argument") + if len(args) > 1: + raise DaxTranslationError("ISEMPTY supports exactly one argument") + with self._allow_cross_table_context(): + table_sql = self._translate_table(args[0]) + return _SqlFragment( + f"NOT EXISTS (SELECT 1 FROM ({table_sql}) AS t)", frozenset(self._columns_from_sql(table_sql)) + ) + + @staticmethod + def _any_column_in_context(target_cols: frozenset[ColumnRef], context_cols: frozenset[ColumnRef]) -> bool: + for target in target_cols: + for context in context_cols: + if _columns_match(target, context): + return True + return False + + def _translate_switch(self, args: list[Any]) -> _SqlFragment: + if len(args) < 3: + raise DaxTranslationError("SWITCH requires expression and at least one value/result pair") + + first = self._translate_scalar(args[0]) + pairs = args[1:] + + is_boolean_switch = self._is_true_literal(args[0]) + when_clauses = [] + columns = set(first.columns) + + idx = 0 + while idx + 1 < len(pairs): + cond_expr = pairs[idx] + result_expr = pairs[idx + 1] + cond = self._translate_scalar(cond_expr) + result = self._translate_scalar(result_expr) + if is_boolean_switch: + when_sql = cond.sql + else: + when_sql = f"{first.sql} = {cond.sql}" + when_clauses.append(f"WHEN {when_sql} THEN {result.sql}") + columns.update(cond.columns) + columns.update(result.columns) + idx += 2 + + else_expr = None + if idx < len(pairs): + else_expr = self._translate_scalar(pairs[idx]) + columns.update(else_expr.columns) + + else_sql = else_expr.sql if else_expr else "NULL" + sql = f"CASE {' '.join(when_clauses)} ELSE {else_sql} END" + return _SqlFragment(sql, frozenset(columns)) + + def _translate_coalesce(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("COALESCE requires at least two arguments") + fragments = [self._translate_scalar(arg) for arg in args] + sql = f"COALESCE({', '.join(f.sql for f in fragments)})" + columns = set() + for frag in fragments: + columns.update(frag.columns) + return _SqlFragment(sql, frozenset(columns)) + + def _translate_divide(self, args: list[Any]) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError("DIVIDE requires numerator and denominator") + if len(args) > 3: + raise DaxTranslationError("DIVIDE supports at most numerator, denominator, and alternate result arguments") + numerator = self._translate_scalar(args[0]) + denominator = self._translate_scalar(args[1]) + alternate = self._translate_scalar(args[2]) if len(args) > 2 else _SqlFragment("NULL", frozenset()) + sql = ( + f"CASE WHEN {denominator.sql} IS NULL OR {denominator.sql} = 0 " + f"THEN {alternate.sql} ELSE {numerator.sql} / {denominator.sql} END" + ) + columns = numerator.columns | denominator.columns | alternate.columns + return _SqlFragment(sql, frozenset(columns)) + + def _translate_and_or(self, args: list[Any], op: str) -> _SqlFragment: + if len(args) < 2: + raise DaxTranslationError(f"{op} requires two arguments") + if len(args) > 2: + raise DaxTranslationError(f"{op} supports exactly two arguments") + left = self._translate_scalar(args[0]) + right = self._translate_scalar(args[1]) + sql = f"({left.sql} {op} {right.sql})" + return _SqlFragment(sql, left.columns | right.columns) + + def _translate_projection_scalar(self, expr: Any) -> _SqlFragment: + try: + return self._translate_scalar(expr) + except DaxTranslationError as exc: + if str(exc) != "DAX table expressions must reference a single base table": + raise + + with self._allow_cross_table_context(): + with self._prefer_unqualified_base_table_context(): + return self._translate_scalar(expr) + + def _translate_in_list(self, expr: Any) -> str: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.TableConstructor): + if not expr.rows: + return "(NULL)" + row_sqls = [] + for row in expr.rows: + if len(row) == 1: + row_sqls.append(self._translate_scalar(row[0]).sql) + else: + row_sqls.append("(" + ", ".join(self._translate_scalar(item).sql for item in row) + ")") + return f"({', '.join(row_sqls)})" + raise DaxTranslationError("IN requires a table constructor") + + def _translate_identifier(self, name: str) -> _SqlFragment: + env_key = name.lower() + if env_key in self._env: + return self._env[env_key] + + if self.model_name is None: + metric = self._translate_metric_reference_name(name) + if metric is not None: + return self._metric_to_scalar_fragment(metric) + elif self._is_measure_name(name): + return _SqlFragment(self._quote_identifier(name), frozenset()) + + if self.model_name is None and self._base_table is None: + raise DaxTranslationError(f"Ambiguous identifier '{name}' without a table context") + + table_name = self.model_name or self._base_table + column_sql = self._column_sql(table_name, name) + columns = frozenset({ColumnRef(table_name, name)}) + return _SqlFragment(column_sql, columns) + + def _translate_table_column(self, table: str, column: str) -> _SqlFragment: + self._ensure_table_context(table) + column_sql = self._column_sql(table, column) + columns = frozenset({ColumnRef(table, column)}) + return _SqlFragment(column_sql, columns) + + def _column_sql(self, table: str | None, column: str) -> str: + if table is not None and self.model_name is not None: + if table.lower() != self.model_name.lower(): + if not self._allow_cross_table: + raise DaxTranslationError( + f"DAX translation only supports references to '{self.model_name}', found '{table}'" + ) + self._required_models.add(table) + elif table is not None and self.model_name is None: + self._required_models.add(table) + + table_key = table or self.model_name or "" + column_map = self.column_sql_by_table.get(table_key, {}) + mapped = column_map.get(column) + if mapped is None: + mapped = self._quote_identifier(column) + + if table is None: + return mapped + if self.model_name and table.lower() == self.model_name.lower(): + return mapped + # In table-query translation (no explicit model), same-table references are rendered + # unqualified so wrapped table expressions remain valid (`FROM () AS t`). + if ( + self.model_name is None + and self._base_table is not None + and table.lower() == self._base_table.lower() + and (not self._allow_cross_table or self._prefer_unqualified_base_table) + ): + return mapped + + table_sql = self._quote_identifier(table) + if _can_qualify_identifier(mapped): + return f"{table_sql}.{mapped}" + return mapped + + def _table_sql(self, table: str) -> str: + return self._quote_identifier(table) + + def _default_table_sql(self) -> str: + if self.model_name: + return self._table_sql(self.model_name) + if self._base_table: + return self._table_sql(self._base_table) + raise DaxTranslationError("No default table context for DAX table expression") + + def _is_measure_name(self, name: str) -> bool: + return self._resolve_measure_reference(name) is not None + + def _resolve_measure_reference(self, measure: str) -> tuple[str, str] | None: + if "." in measure: + table, name = measure.split(".", 1) + return table, name + + if self.model_name: + return self.model_name, measure + + candidates: list[tuple[str, str]] = [] + measure_lower = measure.lower() + for table, measure_names in self.measure_names_by_table.items(): + if measure in measure_names: + candidates.append((table, measure)) + continue + for known in measure_names: + if known.lower() == measure_lower: + candidates.append((table, known)) + break + if len(candidates) == 1: + return candidates[0] + return None + + def _lookup_measure_agg(self, measure: str) -> str | None: + resolved = self._resolve_measure_reference(measure) + if resolved is None: + return None + table, name = resolved + return self.measure_aggs_by_table.get(table, {}).get(name) + + def _lookup_measure_sql(self, measure: str) -> str | None: + resolved = self._resolve_measure_reference(measure) + if resolved is None: + return None + table, name = resolved + return self.measure_sql_by_table.get(table, {}).get(name) + + def _translate_metric_reference(self, expr: Any) -> MetricTranslation | None: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.TableColumnRef): + return self._translate_metric_reference_name(expr.column, table=expr.table.name) + if isinstance(expr, self.dax.BracketRef): + return self._translate_metric_reference_name(expr.name) + if isinstance(expr, self.dax.Identifier): + return self._translate_metric_reference_name(expr.name) + return None + + def _translate_metric_reference_name(self, name: str, table: str | None = None) -> MetricTranslation | None: + if table is not None: + resolved = self._resolve_measure_reference_for_table(table, name) + else: + resolved = self._resolve_measure_reference(name) + if resolved is None: + return None + + table, measure = resolved + agg = self.measure_aggs_by_table.get(table, {}).get(measure) + sql = self.measure_sql_by_table.get(table, {}).get(measure) + filters = list(self.measure_filters_by_table.get(table, {}).get(measure, [])) + if agg is None and sql is None: + return MetricTranslation(sql=self._quote_identifier(measure), type="derived") + + if sql: + sql = self._measure_sql_for_context(table, sql) + elif agg: + sql = f"{table}.{measure}" if self.model_name is not None else None + + return MetricTranslation( + sql=sql, + agg=agg, + type=None if agg else "derived", + source_table=table, + filters=filters, + ) + + def _resolve_measure_reference_for_table(self, table: str, measure: str) -> tuple[str, str] | None: + table_lower = table.lower() + measure_lower = measure.lower() + for known_table, measure_names in self.measure_names_by_table.items(): + if known_table.lower() != table_lower: + continue + for known_measure in measure_names: + if known_measure == measure or known_measure.lower() == measure_lower: + return known_table, known_measure + return None + + def _measure_sql_for_context(self, table: str, sql: str) -> str: + if self.model_name is not None: + if table.lower() == self.model_name.lower(): + return sql + self._required_models.add(table) + return self._qualify_measure_sql(table, sql) + + def _qualify_measure_sql(self, table: str, sql: str | None) -> str | None: + if not sql: + return sql + + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + for column in parsed.find_all(exp.Column): + if column.table: + continue + column.set("table", exp.to_identifier(table)) + return parsed.sql(dialect="duckdb") + except Exception: + if _can_qualify_identifier(sql): + return f"{self._table_sql(table)}.{sql}" + return sql + + def _unwrap(self, expr: Any) -> Any: + while isinstance(expr, self.dax.Paren): + expr = expr.expr + return expr + + def _table_name_from_expr(self, expr: Any) -> str | None: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.TableRef): + return expr.table.name + if isinstance(expr, self.dax.Identifier): + if self._is_known_measure_identifier(expr.name): + return None + if not self._table_exists(expr.name): + return None + return expr.name + if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "relatedtable" and expr.args: + return self._table_name_from_expr(expr.args[0]) + return None + + def _with_vars(self, var_block: Any, func, body: Any): + prior = dict(self._env) + for decl in var_block.decls: + value = self._translate_scalar(decl.expr) + self._env[decl.name.lower()] = value + result = func(body) + self._env = prior + return result + + def _extract_measure_reference(self, expr: Any) -> str | None: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.BracketRef): + return self._qualify_measure(expr.name) + if isinstance(expr, self.dax.Identifier): + return self._qualify_measure(expr.name) + return None + + def _qualify_measure(self, name: str) -> str: + resolved = self._resolve_measure_reference(name) + if resolved is not None: + table, measure = resolved + return f"{table}.{measure}" + return name + + def _ensure_table_context(self, table: str | None) -> None: + if table is None: + return + if self.model_name: + if table.lower() != self.model_name.lower(): + if self._allow_cross_table: + self._required_models.add(table) + return + raise DaxTranslationError( + f"DAX translation only supports references to '{self.model_name}', found '{table}'" + ) + return + if self._base_table is None: + self._base_table = table + return + if table.lower() != self._base_table.lower(): + if self._allow_cross_table: + self._required_models.add(table) + return + raise DaxTranslationError("DAX table expressions must reference a single base table") + + def _append_tables(self, ordered_tables: list[str], seen_tables: set[str], columns: Iterable[ColumnRef]) -> None: + for column in columns: + if not column.table: + continue + key = column.table.lower() + if key in seen_tables: + continue + ordered_tables.append(column.table) + seen_tables.add(key) + + def _tables_for_scalar_fragment(self, fragment: _SqlFragment) -> list[str]: + referenced_tables: dict[str, str] = {} + for column in fragment.columns: + if not column.table: + continue + key = column.table.lower() + referenced_tables.setdefault(key, column.table) + + tables: list[str] = [] + if self._base_table and self._base_table.lower() in referenced_tables: + tables.append(referenced_tables.pop(self._base_table.lower())) + for _, table_name in sorted(referenced_tables.items(), key=lambda item: item[0]): + tables.append(table_name) + return tables + + def _scalar_fragment_sql_with_from(self, fragment: _SqlFragment) -> str: + tables = self._tables_for_scalar_fragment(fragment) + if not tables: + return fragment.sql + from_clause = self._build_from_clause_for_tables(tables) + return f"(SELECT {fragment.sql} FROM {from_clause})" + + def _build_relationship_adjacency(self, edges: list[RelationshipEdge]) -> dict[str, list[tuple[str, str, str]]]: + adjacency: dict[str, list[tuple[str, str, str]]] = {} + for edge in edges: + from_table = edge.from_table + to_table = edge.to_table + adjacency.setdefault(from_table, []).append((to_table, edge.from_column, edge.to_column)) + adjacency.setdefault(to_table, []).append((from_table, edge.to_column, edge.from_column)) + return adjacency + + def _find_relationship_path(self, base_table: str, target_table: str) -> list[tuple[str, str, str, str]] | None: + if base_table == target_table: + return [] + + visited = {base_table} + queue = deque([base_table]) + parent: dict[str, tuple[str, str, str]] = {} + + while queue: + current = queue.popleft() + for next_table, current_col, next_col in self._relationship_adjacency.get(current, []): + if next_table in visited: + continue + visited.add(next_table) + parent[next_table] = (current, current_col, next_col) + if next_table == target_table: + path: list[tuple[str, str, str, str]] = [] + node = target_table + while node != base_table: + prev, prev_col, node_col = parent[node] + path.append((prev, node, prev_col, node_col)) + node = prev + path.reverse() + return path + queue.append(next_table) + + return None + + def _find_relationship_path_from_joined( + self, joined_tables: list[str], target_table: str + ) -> list[tuple[str, str, str, str]] | None: + best_path: list[tuple[str, str, str, str]] | None = None + for anchor in joined_tables: + path = self._find_relationship_path(anchor, target_table) + if path is None: + continue + if best_path is None or len(path) < len(best_path): + best_path = path + return best_path + + def _build_from_clause_for_tables(self, tables_in_order: list[str]) -> str: + if not tables_in_order: + return self._default_table_sql() + + base_table = tables_in_order[0] + from_parts = [self._table_sql(base_table)] + joined_tables = {base_table.lower()} + joined_order = [base_table] + + for table in tables_in_order[1:]: + table_key = table.lower() + if table_key in joined_tables: + continue + path = self._find_relationship_path_from_joined(joined_order, table) + if path is None: + if self._allow_unrelated_table_cross_join: + self._append_unrelated_cross_join_warning(base_table, table) + from_parts.append(f"CROSS JOIN {self._table_sql(table)}") + joined_tables.add(table_key) + joined_order.append(table) + continue + raise DaxTranslationError(f"No relationship path between {base_table} and {table}") + for from_table, to_table, from_col, to_col in path: + to_key = to_table.lower() + if to_key in joined_tables: + continue + left_table = self._table_sql(from_table) + right_table = self._table_sql(to_table) + from_col_sql = self._quote_identifier(from_col) + to_col_sql = self._quote_identifier(to_col) + from_parts.append( + f"LEFT JOIN {right_table} ON {left_table}.{from_col_sql} = {right_table}.{to_col_sql}" + ) + joined_tables.add(to_key) + joined_order.append(to_table) + + return " ".join(from_parts) + + def _append_unrelated_cross_join_warning(self, base_table: str, table: str) -> None: + key = ("dax_unrelated_cross_join", base_table.lower(), table.lower()) + if key in self._warning_keys: + return + self._warning_keys.add(key) + self._warnings.append( + { + "code": "dax_unrelated_cross_join", + "context": "query", + "base_table": base_table, + "table": table, + "message": ( + f"DAX query cross joins unrelated table '{table}' with '{base_table}' " + "because no relationship path is defined" + ), + } + ) + + def _validate_time_argument(self, expr: Any | None) -> None: + if not expr: + return + candidate = self._unwrap(expr) + if isinstance(candidate, self.dax.HierarchyRef): + table = candidate.table.name + column = candidate.levels[-1] if candidate.levels else candidate.column + self._validate_time_dimension(table, column) + return + if isinstance(candidate, self.dax.TableColumnRef): + self._validate_time_dimension(candidate.table.name, candidate.column) + return + if self.time_dimensions_by_table: + raise DaxTranslationError("Time intelligence requires a table time column argument") + + def _validate_time_dimension(self, table: str | None, column: str) -> None: + if not self.time_dimensions_by_table: + return + table_name = table or self.model_name + if not table_name: + return + known_dims = self.time_dimensions_by_table.get(table_name) + if known_dims is None: + known_dims = set() + for key, value in self.time_dimensions_by_table.items(): + if key.lower() == table_name.lower(): + known_dims = value + break + if known_dims and column in known_dims: + return + raise DaxTranslationError(f"{table_name}[{column}] is not a known time dimension") + + def _allow_cross_table_context(self): + class _Context: + def __init__(self, outer): + self.outer = outer + self.prior = outer._allow_cross_table + + def __enter__(self): + self.outer._allow_cross_table = True + return self + + def __exit__(self, exc_type, exc, tb): + self.outer._allow_cross_table = self.prior + return False + + return _Context(self) + + def _prefer_unqualified_base_table_context(self): + class _Context: + def __init__(self, outer): + self.outer = outer + self.prior = outer._prefer_unqualified_base_table + + def __enter__(self): + self.outer._prefer_unqualified_base_table = True + return self + + def __exit__(self, exc_type, exc, tb): + self.outer._prefer_unqualified_base_table = self.prior + return False + + return _Context(self) + + def _measure_eval_context(self, group_by_cols: set[ColumnRef], filter_cols: set[ColumnRef]): + class _Context: + def __init__(self, outer): + self.outer = outer + self.prior_group = outer._current_group_by_columns + self.prior_filter = outer._current_filter_columns + self.group = frozenset(group_by_cols) + self.filters = frozenset(filter_cols) + + def __enter__(self): + self.outer._current_group_by_columns = self.group + self.outer._current_filter_columns = self.filters + return self + + def __exit__(self, exc_type, exc, tb): + self.outer._current_group_by_columns = self.prior_group + self.outer._current_filter_columns = self.prior_filter + return False + + return _Context(self) + + def _parse_dateadd(self, call: Any) -> tuple[int, str] | None: + if len(call.args) < 3: + return None + offset = self._number_literal_value(call.args[1]) + unit = self._identifier_literal_value(call.args[2]) + if offset is None or unit is None: + return None + normalized_unit = unit.lower() + if normalized_unit.endswith("s"): + normalized_unit = normalized_unit[:-1] + # DAX DATEADD direction is inverse of row-offset semantics used by + # window LAG/LEAD generation: -1 YEAR means prior period (lag +1). + return -offset, normalized_unit + + def _string_literal_value(self, expr: Any) -> str | None: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.String): + return expr.value + return None + + def _identifier_literal_value(self, expr: Any) -> str | None: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.Identifier): + return expr.name + if isinstance(expr, self.dax.String): + return expr.value + return None + + def _number_literal_value(self, expr: Any) -> int | None: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.Number): + try: + return int(float(expr.value)) + except ValueError: + return None + if isinstance(expr, self.dax.Unary): + inner = self._number_literal_value(expr.expr) + if inner is None: + return None + op_name = getattr(expr.op, "name", str(expr.op)).lower() + if "minus" in op_name: + return -inner + if "plus" in op_name: + return inner + return None + return None + + def _topn_numeric_arg_sql(self, expr: Any, *, function_name: str, arg_name: str) -> str: + literal_value = self._number_literal_value(expr) + if literal_value is not None: + return str(literal_value) + + try: + with self._allow_cross_table_context(): + fragment = self._translate_scalar(expr) + except DaxTranslationError as exc: + raise DaxTranslationError(f"{function_name} {arg_name} must be a number") from exc + + if fragment.columns: + raise DaxTranslationError(f"{function_name} {arg_name} must be a number") + return f"CAST(({fragment.sql}) AS BIGINT)" + + def _is_true_literal(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.Boolean): + return expr.value is True + if isinstance(expr, self.dax.FunctionCall): + return expr.name.lower() == "true" and not expr.args + if isinstance(expr, self.dax.Identifier) and expr.name.lower() == "true": + return True + return False + + def _is_blank_expr(self, expr: Any) -> bool: + expr = self._unwrap(expr) + if isinstance(expr, self.dax.Blank): + return True + if isinstance(expr, self.dax.FunctionCall): + return expr.name.lower() == "blank" and not expr.args + if isinstance(expr, self.dax.Identifier): + return expr.name.lower() == "blank" + return False + + @staticmethod + def _quote_string(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + @staticmethod + def _quote_identifier(name: str) -> str: + if _is_safe_identifier(name): + return name + escaped = name.replace('"', '""') + return f'"{escaped}"' + + +@dataclass(frozen=True) +class _SqlFragment: + sql: str + columns: frozenset[ColumnRef] + + def wrap(self, sql: str) -> _SqlFragment: + return _SqlFragment(sql, self.columns) + + +@dataclass(frozen=True) +class _OrderKeySql: + direct_expr_sql: str + direct_order_sql: str + wrapped_order_sql: str + wrapped_ref_sql: str + direction: str + + +class _DefineResolver: + _AMBIGUOUS = object() + + def __init__(self, dax_ast: Any, define_block: Any | None) -> None: + self.dax = dax_ast + self._measure_defs: dict[str, Any] = {} + self._table_defs: dict[str, Any] = {} + self._var_defs: dict[str, Any] = {} + self._function_defs: dict[str, Any] = {} + self._column_defs: dict[tuple[str | None, str], Any] = {} + self._column_defs_by_name: dict[str, Any] = {} + + if define_block is None: + return + + for definition in define_block.defs: + if isinstance(definition, self.dax.MeasureDef): + self._measure_defs[definition.name.lower()] = definition.expr + elif isinstance(definition, self.dax.TableDef): + self._table_defs[definition.name.lower()] = definition.expr + elif isinstance(definition, self.dax.VarDef): + self._var_defs[definition.name.lower()] = definition.expr + elif isinstance(definition, self.dax.FunctionDef): + self._function_defs[definition.name.lower()] = definition + elif isinstance(definition, self.dax.ColumnDef): + table_name = definition.table.name.lower() if definition.table else None + col_name = definition.name.lower() + self._column_defs[(table_name, col_name)] = definition.expr + current = self._column_defs_by_name.get(col_name) + if current is None: + self._column_defs_by_name[col_name] = definition.expr + else: + self._column_defs_by_name[col_name] = self._AMBIGUOUS + + def resolve_expr(self, expr: Any) -> Any: + return self._resolve(expr, stack=(), bindings={}) + + def _resolve( + self, + expr: Any, + stack: tuple[tuple[str, str], ...], + bindings: dict[str, Any], + ) -> Any: + if isinstance(expr, self.dax.Paren): + return self.dax.Paren(expr=self._resolve(expr.expr, stack, bindings)) + + if isinstance(expr, self.dax.BracketRef): + key = ("measure", expr.name.lower()) + target = self._measure_defs.get(key[1]) + if target is not None: + if key in stack: + raise DaxTranslationError(f"Cyclic DEFINE MEASURE reference for [{expr.name}]") + return self.dax.Paren(self._resolve(target, stack + (key,), bindings)) + column_target = self._column_defs_by_name.get(expr.name.lower()) + if column_target is self._AMBIGUOUS: + return expr + if column_target is not None: + column_key = ("column", expr.name.lower()) + if column_key in stack: + raise DaxTranslationError(f"Cyclic DEFINE COLUMN reference for [{expr.name}]") + return self.dax.Paren(self._resolve(column_target, stack + (column_key,), bindings)) + return expr + + if isinstance(expr, self.dax.TableRef): + key = ("table", expr.table.name.lower()) + target = self._table_defs.get(key[1]) + if target is None: + return expr + if key in stack: + raise DaxTranslationError(f"Cyclic DEFINE TABLE reference for {expr.table.name}") + return self._resolve(target, stack + (key,), bindings) + + if isinstance(expr, self.dax.TableColumnRef): + key_name = (expr.table.name.lower(), expr.column.lower()) + target = self._column_defs.get(key_name) + if target is None: + return expr + key = ("column", f"{expr.table.name.lower()}.{expr.column.lower()}") + if key in stack: + raise DaxTranslationError(f"Cyclic DEFINE COLUMN reference for {expr.table.name}[{expr.column}]") + return self.dax.Paren(self._resolve(target, stack + (key,), bindings)) + + if isinstance(expr, self.dax.Identifier): + bound = bindings.get(expr.name.lower()) + if bound is not None: + return bound + key = ("var", expr.name.lower()) + target = self._var_defs.get(key[1]) + if target is not None: + if key in stack: + raise DaxTranslationError(f"Cyclic DEFINE VAR reference for {expr.name}") + return self.dax.Paren(self._resolve(target, stack + (key,), bindings)) + + table_target = self._table_defs.get(expr.name.lower()) + if table_target is not None: + table_key = ("table", expr.name.lower()) + if table_key in stack: + raise DaxTranslationError(f"Cyclic DEFINE TABLE reference for {expr.name}") + return self._resolve(table_target, stack + (table_key,), bindings) + + return expr + + if isinstance(expr, self.dax.FunctionCall): + resolved_args = [self._resolve(arg, stack, bindings) for arg in expr.args] + function_def = self._function_defs.get(expr.name.lower()) + if function_def is None: + return self.dax.FunctionCall(name=expr.name, args=resolved_args) + + if len(resolved_args) != len(function_def.params): + raise DaxTranslationError( + f"DEFINE FUNCTION {function_def.name} expects {len(function_def.params)} args, got {len(resolved_args)}" + ) + + key = ("function", function_def.name.lower()) + if key in stack: + raise DaxTranslationError(f"Cyclic DEFINE FUNCTION reference for {function_def.name}") + + scoped_bindings = dict(bindings) + for param, arg in zip(function_def.params, resolved_args, strict=False): + scoped_bindings[param.name.lower()] = arg + + return self.dax.Paren(self._resolve(function_def.body, stack + (key,), scoped_bindings)) + + if isinstance(expr, self.dax.Unary): + return self.dax.Unary(op=expr.op, expr=self._resolve(expr.expr, stack, bindings)) + + if isinstance(expr, self.dax.Binary): + return self.dax.Binary( + op=expr.op, + left=self._resolve(expr.left, stack, bindings), + right=self._resolve(expr.right, stack, bindings), + ) + + if isinstance(expr, self.dax.VarBlock): + scoped_bindings = dict(bindings) + decls = [] + for decl in expr.decls: + resolved_decl = self._resolve(decl.expr, stack, scoped_bindings) + decls.append(self.dax.VarDecl(name=decl.name, expr=resolved_decl)) + scoped_bindings[decl.name.lower()] = resolved_decl + body = self._resolve(expr.body, stack, scoped_bindings) + return self.dax.VarBlock(decls=decls, body=body) + + if isinstance(expr, self.dax.TableConstructor): + rows = [[self._resolve(value, stack, bindings) for value in row] for row in expr.rows] + return self.dax.TableConstructor(rows=rows) + + return expr + + +def _translate_order_keys(stmt: Any, translator: _DaxTranslator, resolver: _DefineResolver) -> list[_OrderKeySql]: + order_keys: list[_OrderKeySql] = [] + for key in stmt.order_by: + resolved_expr = resolver.resolve_expr(key.expr) + with translator._allow_cross_table_context(): + fragment = translator._translate_scalar(resolved_expr) + + direction = "ASC" if key.direction == translator.dax.SortDirection.asc else "DESC" + direct_order_sql = f"{fragment.sql} {direction}" + + wrapped_expr_sql = _rewrite_order_expr_for_wrapped(fragment.sql) + wrapped_ref_sql = wrapped_expr_sql + wrapped_order_sql = f"{wrapped_expr_sql} {direction}" + + order_keys.append( + _OrderKeySql( + direct_expr_sql=fragment.sql, + direct_order_sql=direct_order_sql, + wrapped_order_sql=wrapped_order_sql, + wrapped_ref_sql=wrapped_ref_sql, + direction=direction, + ) + ) + + return order_keys + + +def _apply_order_and_start_at( + base_sql: str, + stmt: Any, + translator: _DaxTranslator, + resolver: _DefineResolver, + order_keys: list[_OrderKeySql], +) -> str: + if stmt.start_at: + if not order_keys: + raise DaxTranslationError("START AT requires ORDER BY") + + if len(stmt.start_at) > len(order_keys): + raise DaxTranslationError("START AT has more arguments than ORDER BY") + + value_sql: list[str] = [] + for value in stmt.start_at: + resolved = resolver.resolve_expr(value) + value_sql.append(translator._translate_scalar(resolved).sql) + + start_order_keys = order_keys[: len(value_sql)] + predicate = _build_start_at_predicate(start_order_keys, value_sql) + sql = f"SELECT * FROM ({base_sql}) AS q WHERE {predicate}" + wrapped_order = [key.wrapped_order_sql for key in order_keys] + return f"{sql} ORDER BY {', '.join(wrapped_order)}" + + if not order_keys: + return base_sql + + wrapped_order = [key.wrapped_order_sql for key in order_keys] + return f"SELECT * FROM ({base_sql}) AS q ORDER BY {', '.join(wrapped_order)}" + + +def _build_start_at_predicate(order_keys: list[_OrderKeySql], start_values_sql: list[str]) -> str: + disjuncts: list[str] = [] + for idx, value_sql in enumerate(start_values_sql): + conjuncts: list[str] = [] + for prev_idx in range(idx): + prev_ref = order_keys[prev_idx].wrapped_ref_sql + conjuncts.append(f"{prev_ref} = {start_values_sql[prev_idx]}") + + ref = order_keys[idx].wrapped_ref_sql + + is_last = idx == len(start_values_sql) - 1 + direction = order_keys[idx].direction + if direction == "ASC": + op = ">=" if is_last else ">" + else: + op = "<=" if is_last else "<" + conjuncts.append(f"{ref} {op} {value_sql}") + disjuncts.append("(" + " AND ".join(conjuncts) + ")") + + return " OR ".join(disjuncts) + + +def _rewrite_order_expr_for_wrapped(sql: str) -> str: + return _rewrite_expr_for_alias(sql, "q") + + +def _rewrite_expr_for_alias( + sql: str, + alias: str, + source_tables: set[str] | None = None, + source_columns: set[str] | None = None, + source_column_aliases: dict[str, str] | None = None, + ambiguous_source_aliases: set[str] | None = None, + local_columns: set[str] | None = None, + ambiguous_source_columns: set[str] | None = None, + allow_fallback: bool = True, + strict_source_resolution: bool = False, +) -> str: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + source_tables_lower = {table.lower() for table in (source_tables or set())} + source_columns_lower = {column.lower() for column in (source_columns or set())} + ambiguous_source_aliases_lower = {name.lower() for name in (ambiguous_source_aliases or set())} + local_columns_lower = {column.lower() for column in (local_columns or set())} + ambiguous_source_columns_lower = {column.lower() for column in (ambiguous_source_columns or set())} + source_has_wildcard = "*" in source_columns_lower + source_has_multiple_tables = len(source_tables_lower) > 1 + source_aliases = source_column_aliases or {} + for column in parsed.find_all(exp.Column): + replacement_name: str | None = None + if source_tables_lower: + table_name = column.table + if table_name: + if table_name.lower() not in source_tables_lower: + continue + if _column_table_is_local_to_select(column): + continue + qualified_key = f"{table_name.lower()}.{column.name.lower()}" + if ( + qualified_key in ambiguous_source_aliases_lower + or column.name.lower() in ambiguous_source_aliases_lower + ): + if strict_source_resolution: + raise DaxTranslationError( + f"Ambiguous outer column reference '{table_name}.{column.name}' for alias '{alias}'" + ) + continue + replacement_name = source_aliases.get(qualified_key) or source_aliases.get(column.name.lower()) + if ( + replacement_name is None + and not source_has_wildcard + and column.name.lower() not in source_columns_lower + ): + continue + if replacement_name is None and source_has_wildcard and source_has_multiple_tables: + if strict_source_resolution: + raise DaxTranslationError( + f"Ambiguous outer column reference '{table_name}.{column.name}' for alias '{alias}'" + ) + continue + if replacement_name is None and column.name.lower() in ambiguous_source_columns_lower: + if strict_source_resolution: + raise DaxTranslationError( + f"Ambiguous outer column reference '{column.name}' for alias '{alias}'" + ) + continue + elif source_columns_lower: + column_key = column.name.lower() + if _column_name_is_local_to_select(column, local_columns_lower): + continue + if column_key in ambiguous_source_aliases_lower: + if strict_source_resolution: + raise DaxTranslationError( + f"Ambiguous outer column reference '{column.name}' for alias '{alias}'" + ) + continue + replacement_name = source_aliases.get(column_key) + if column_key not in source_columns_lower and replacement_name is None: + continue + if replacement_name is None and column_key in ambiguous_source_columns_lower: + if strict_source_resolution: + raise DaxTranslationError( + f"Ambiguous outer column reference '{column.name}' for alias '{alias}'" + ) + continue + else: + continue + column.set("table", exp.to_identifier("q")) + if alias != "q": + column.set("table", exp.to_identifier(alias)) + if replacement_name: + column.set("this", exp.to_identifier(replacement_name)) + return parsed.sql(dialect="duckdb") + except DaxTranslationError: + raise + except Exception as exc: + if strict_source_resolution and not allow_fallback: + raise DaxTranslationError( + f"Unable to safely correlate outer column references for alias '{alias}'" + ) from exc + if not allow_fallback: + return sql + return _fallback_rewrite_expr_for_alias(sql, alias, source_tables, source_columns) + + +_QUALIFIED_TABLE_PREFIX_RE = re.compile(r'("(?:""|[^"])*"|[A-Za-z_][A-Za-z0-9_]*)\.') + + +def _fallback_rewrite_expr_for_alias( + sql: str, + alias: str, + source_tables: set[str] | None = None, + source_columns: set[str] | None = None, +) -> str: + # Best-effort rewrite for SQLGlot parse failures. Keep expression usable against + # SELECT * FROM () AS alias by collapsing table qualifiers. + if source_tables: + rewritten = sql + source_columns_lower = {column.lower() for column in (source_columns or set())} + source_has_wildcard = "*" in source_columns_lower + for table in source_tables: + table_quoted = table.replace('"', '""') + if source_columns_lower and not source_has_wildcard: + for column in source_columns_lower: + if column == "*": + continue + rewritten = re.sub( + rf"\b{re.escape(table)}\.{re.escape(column)}\b", + f"{alias}.{column}", + rewritten, + flags=re.IGNORECASE, + ) + rewritten = rewritten.replace( + f'"{table_quoted}"."{column}"', + f"{alias}.{column}", + ) + else: + rewritten = re.sub(rf"\b{re.escape(table)}\.", f"{alias}.", rewritten, flags=re.IGNORECASE) + rewritten = rewritten.replace(f'"{table_quoted}".', f"{alias}.") + return rewritten + + rewritten = _QUALIFIED_TABLE_PREFIX_RE.sub(f"{alias}.", sql) + stripped = rewritten.strip() + if "." not in stripped and _can_qualify_identifier(stripped): + return f"{alias}.{stripped}" + return rewritten + + +def _column_table_is_local_to_select(column_expr: Any) -> bool: + try: + from sqlglot import exp + + table_name = getattr(column_expr, "table", None) + if not table_name: + return False + table_key = table_name.lower() + node = column_expr + while node is not None: + if isinstance(node, exp.Select): + if table_key in _select_scope_table_names(node): + return True + node = getattr(node, "parent", None) + return False + except Exception: + return False + + +def _column_name_is_local_to_select(column_expr: Any, known_local_columns: set[str] | None = None) -> bool: + try: + from sqlglot import exp + + column_name = getattr(column_expr, "name", None) + if not column_name: + return False + column_key = column_name.lower() + if known_local_columns and column_key in known_local_columns: + return True + if getattr(column_expr, "table", None): + return _column_table_is_local_to_select(column_expr) + + node = column_expr + while node is not None: + if isinstance(node, exp.Select): + for source_expr in _select_scope_source_exprs(node): + if _source_expr_exposes_column(source_expr, column_key): + return True + node = getattr(node, "parent", None) + return False + except Exception: + if not known_local_columns: + return False + column_name = getattr(column_expr, "name", None) + return bool(column_name and column_name.lower() in known_local_columns) + + +def _select_scope_source_exprs(select_expr: Any) -> list[Any]: + source_exprs: list[Any] = [] + from_expr = select_expr.args.get("from") + if from_expr is not None and getattr(from_expr, "this", None) is not None: + source_exprs.append(from_expr.this) + for join_expr in select_expr.args.get("joins") or []: + if getattr(join_expr, "this", None) is not None: + source_exprs.append(join_expr.this) + return source_exprs + + +def _source_expr_exposes_column(source_expr: Any, column_key: str) -> bool: + try: + from sqlglot import exp + + alias = source_expr.args.get("alias") + if isinstance(alias, exp.TableAlias): + alias_columns = [col.name.lower() for col in alias.columns if getattr(col, "name", None)] + if alias_columns: + return column_key in alias_columns + + if isinstance(source_expr, exp.Subquery): + return column_key in _query_expr_output_columns(source_expr.this) + if isinstance(source_expr, exp.Values): + if isinstance(alias, exp.TableAlias): + alias_columns = [col.name.lower() for col in alias.columns if getattr(col, "name", None)] + return column_key in alias_columns + return False + if isinstance(source_expr, exp.Paren): + return _source_expr_exposes_column(source_expr.this, column_key) + return False + except Exception: + return False + + +def _query_expr_output_columns(expr: Any) -> set[str]: + try: + from sqlglot import exp + + if isinstance(expr, exp.Subquery): + return _query_expr_output_columns(expr.this) + if isinstance(expr, exp.Paren): + return _query_expr_output_columns(expr.this) + if isinstance(expr, exp.Select): + names: set[str] = set() + for projection in expr.expressions: + if isinstance(projection, exp.Star): + names.update(name.lower() for name in _select_star_output_columns(expr)) + continue + if isinstance(projection, exp.Column) and projection.name == "*": + qualifier = projection.table if projection.table else None + names.update(name.lower() for name in _select_star_output_columns(expr, qualifier)) + continue + output_name = projection.alias_or_name + if output_name and output_name != "*": + names.add(output_name.lower()) + return names + if isinstance(expr, (exp.Union, exp.Intersect, exp.Except)): + return _query_expr_output_columns(expr.this) + return set() + except Exception: + return set() + + +def _select_star_output_columns(select_expr: Any, qualifier: str | None = None) -> set[str]: + columns: set[str] = set() + qualifier_key = qualifier.lower() if qualifier else None + for source_expr in _select_scope_source_exprs(select_expr): + if qualifier_key is not None: + source_keys: set[str] = set() + alias = getattr(source_expr, "alias_or_name", None) + if alias: + source_keys.add(alias.lower()) + table_name = getattr(source_expr, "name", None) + if table_name: + source_keys.add(table_name.lower()) + if qualifier_key not in source_keys: + continue + columns.update(_source_expr_output_columns(source_expr)) + return columns + + +def _source_expr_output_columns(source_expr: Any) -> set[str]: + try: + from sqlglot import exp + + alias = source_expr.args.get("alias") + if isinstance(alias, exp.TableAlias): + alias_columns = [col.name for col in alias.columns if getattr(col, "name", None)] + if alias_columns: + return set(alias_columns) + + if isinstance(source_expr, exp.Subquery): + return _query_expr_output_column_names(source_expr.this) + if isinstance(source_expr, exp.Values): + if isinstance(alias, exp.TableAlias): + alias_columns = [col.name for col in alias.columns if getattr(col, "name", None)] + return set(alias_columns) + return set() + if isinstance(source_expr, exp.Paren): + return _source_expr_output_columns(source_expr.this) + return set() + except Exception: + return set() + + +def _query_expr_output_column_names(expr: Any) -> set[str]: + try: + from sqlglot import exp + + if isinstance(expr, exp.Subquery): + return _query_expr_output_column_names(expr.this) + if isinstance(expr, exp.Paren): + return _query_expr_output_column_names(expr.this) + if isinstance(expr, exp.Select): + names: set[str] = set() + for projection in expr.expressions: + if isinstance(projection, exp.Star): + names.update(_select_star_output_columns(expr)) + continue + if isinstance(projection, exp.Column) and projection.name == "*": + qualifier = projection.table if projection.table else None + names.update(_select_star_output_columns(expr, qualifier)) + continue + output_name = projection.alias_or_name + if output_name and output_name != "*": + names.add(output_name) + return names + if isinstance(expr, (exp.Union, exp.Intersect, exp.Except)): + return _query_expr_output_column_names(expr.this) + return set() + except Exception: + return set() + + +def _select_scope_table_names(select_expr: Any) -> set[str]: + try: + names: set[str] = set() + source_exprs = _select_scope_source_exprs(select_expr) + + from sqlglot import exp + + for source_expr in source_exprs: + alias = getattr(source_expr, "alias_or_name", None) + if alias: + names.add(alias.lower()) + if isinstance(source_expr, exp.Table): + table_name = source_expr.name + if table_name: + names.add(table_name.lower()) + return names + except Exception: + return set() + + +def _identifier_name_from_sql(sql: str) -> str | None: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + if isinstance(parsed, exp.Identifier): + return parsed.name + if isinstance(parsed, exp.Column): + return parsed.name + except Exception: + return None + return None + + +def _column_name_from_expr_sql(sql: str) -> str | None: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + if isinstance(parsed, exp.Column): + return parsed.name + if isinstance(parsed, exp.Identifier): + return parsed.name + except Exception: + return None + return None + + +def _query_output_columns(sql: str) -> set[str]: + try: + import sqlglot + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + return _query_expr_output_column_names(parsed) + except Exception: + return set() + + +def _query_expr_output_column_name_counts(expr: Any) -> dict[str, int]: + try: + from sqlglot import exp + + if isinstance(expr, exp.Subquery): + return _query_expr_output_column_name_counts(expr.this) + if isinstance(expr, exp.Paren): + return _query_expr_output_column_name_counts(expr.this) + if isinstance(expr, exp.Select): + counts: dict[str, int] = {} + for projection in expr.expressions: + if isinstance(projection, exp.Star): + for name in _select_star_output_columns(expr): + key = name.lower() + counts[key] = counts.get(key, 0) + 1 + continue + if isinstance(projection, exp.Column) and projection.name == "*": + qualifier = projection.table if projection.table else None + for name in _select_star_output_columns(expr, qualifier): + key = name.lower() + counts[key] = counts.get(key, 0) + 1 + continue + output_name = projection.alias_or_name + if output_name and output_name != "*": + key = output_name.lower() + counts[key] = counts.get(key, 0) + 1 + return counts + if isinstance(expr, (exp.Union, exp.Intersect, exp.Except)): + return _query_expr_output_column_name_counts(expr.this) + return {} + except Exception: + return {} + + +def _query_output_column_name_counts(sql: str) -> dict[str, int]: + try: + import sqlglot + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + return _query_expr_output_column_name_counts(parsed) + except Exception: + return {} + + +def _query_source_table_names(sql: str) -> set[str]: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + table_names: set[str] = set() + for table_expr in parsed.find_all(exp.Table): + table_name = table_expr.name + if table_name: + table_names.add(table_name) + return table_names + except Exception: + return set() + + +def _query_output_width(sql: str) -> int | None: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + select_exprs = parsed.selects if hasattr(parsed, "selects") else [] + if not select_exprs: + return None + for expr in select_exprs: + if isinstance(expr, exp.Star): + return None + if isinstance(expr, exp.Column) and expr.name == "*": + return None + return len(select_exprs) + except Exception: + return None + + +def _query_uses_star_projection(sql: str) -> bool: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + select_exprs = parsed.selects if hasattr(parsed, "selects") else [] + for expr in select_exprs: + if isinstance(expr, exp.Star): + return True + if isinstance(expr, exp.Column) and expr.name == "*": + return True + return False + except Exception: + return False + + +def _query_star_projection_qualifiers(sql: str) -> set[str | None]: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + qualifiers: set[str | None] = set() + select_exprs = parsed.selects if hasattr(parsed, "selects") else [] + for expr in select_exprs: + if isinstance(expr, exp.Star): + qualifiers.add(None) + continue + if isinstance(expr, exp.Column) and expr.name == "*": + qualifiers.add(expr.table or None) + return qualifiers + except Exception: + return set() + + +def _query_output_lineage_aliases(sql: str) -> tuple[dict[str, str], set[str]]: + try: + import sqlglot + from sqlglot import exp + + parsed = sqlglot.parse_one(sql, dialect="duckdb") + select_exprs = parsed.selects if hasattr(parsed, "selects") else [] + aliases: dict[str, str] = {} + ambiguous: set[str] = set() + for expr in select_exprs: + output_name = expr.alias_or_name + if not output_name: + continue + + source_expr = expr.this if isinstance(expr, exp.Alias) else expr + if isinstance(source_expr, exp.Column): + source_name = source_expr.name + if not source_name: + continue + source_keys = [source_name.lower()] + source_table = source_expr.table + if source_table: + source_keys.append(f"{source_table.lower()}.{source_name.lower()}") + for source_key in source_keys: + if source_key in ambiguous: + continue + if source_key in aliases: + ambiguous.add(source_key) + aliases.pop(source_key, None) + continue + aliases[source_key] = output_name + return aliases, ambiguous + except Exception: + return {}, set() + + +def _is_unsupported_table_expression_error(exc: DaxTranslationError) -> bool: + message = str(exc) + return message.startswith("Unsupported table function '") or message.startswith( + "Unsupported table expression type '" + ) + + +def _order_ref_name(expr: Any, dax_ast: Any) -> str | None: + while isinstance(expr, dax_ast.Paren): + expr = expr.expr + + if isinstance(expr, dax_ast.TableColumnRef): + return expr.column + if isinstance(expr, dax_ast.HierarchyRef): + return expr.levels[-1] if expr.levels else expr.column + if isinstance(expr, dax_ast.BracketRef): + return expr.name + if isinstance(expr, dax_ast.Identifier): + return expr.name + return None + + +def _is_safe_identifier(name: str) -> bool: + if not name: + return False + first = name[0] + if not (first.isalpha() or first == "_"): + return False + for ch in name[1:]: + if not (ch.isalnum() or ch == "_"): + return False + return True + + +def _can_qualify_identifier(sql: str) -> bool: + if _is_safe_identifier(sql): + return True + if sql.startswith('"') and sql.endswith('"') and "(" not in sql and " " not in sql: + return True + return False + + +def _columns_match(left: ColumnRef, right: ColumnRef) -> bool: + if left.column.lower() != right.column.lower(): + return False + if left.table and right.table and left.table.lower() != right.table.lower(): + return False + return True + + +def _comparison_type_for_unit(unit: str) -> str: + return { + "day": "dod", + "week": "wow", + "month": "mom", + "quarter": "qoq", + "year": "yoy", + }.get(unit, "prior_period") + + +def _time_offset_for_period_function(name: str) -> tuple[int, str] | None: + return { + "previousday": (1, "day"), + "previousweek": (1, "week"), + "previousmonth": (1, "month"), + "previousquarter": (1, "quarter"), + "previousyear": (1, "year"), + "nextday": (-1, "day"), + "nextweek": (-1, "week"), + "nextmonth": (-1, "month"), + "nextquarter": (-1, "quarter"), + "nextyear": (-1, "year"), + }.get(name) + + +def _load_dax_ast(): + try: + from sidemantic_dax import ast as dax_ast + except Exception as exc: + raise DaxTranslationError("sidemantic_dax is required for DAX translation") from exc + return dax_ast diff --git a/sidemantic/loaders.py b/sidemantic/loaders.py index 57da9c4a..47e13161 100644 --- a/sidemantic/loaders.py +++ b/sidemantic/loaders.py @@ -1,5 +1,6 @@ """Auto-discovery loaders for semantic layer definitions.""" +import copy import logging import runpy import sys @@ -38,6 +39,7 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: from sidemantic.adapters.snowflake import SnowflakeAdapter from sidemantic.adapters.superset import SupersetAdapter from sidemantic.adapters.thoughtspot import ThoughtSpotAdapter + from sidemantic.adapters.tmdl import TMDLAdapter from sidemantic.adapters.yardstick import YardstickAdapter directory = Path(directory) @@ -48,24 +50,61 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: all_models = {} all_metrics = {} all_parameters = {} + import_warnings: list[dict[str, object]] = [] # Check for SML repository (catalog.yml/atscale.yml or object_type files) if _try_load_sml(layer, directory, all_models): return + # TMDL projects are folder-based. Parse a project root once instead of + # treating each .tmdl file as an independent model. + tmdl_root = None + definition_dir = directory / "definition" + if definition_dir.is_dir() and list(definition_dir.rglob("*.tmdl")): + tmdl_root = definition_dir + elif list(directory.rglob("*.tmdl")): + tmdl_root = directory + + if tmdl_root: + try: + graph = TMDLAdapter().parse(tmdl_root) + _merge_graph_passthrough_metadata(layer.graph, graph) + _extend_import_warnings(import_warnings, graph) + for model in graph.models.values(): + if not hasattr(model, "_source_format"): + model._source_format = "TMDL" + if not hasattr(model, "_source_file"): + model._source_file = str(tmdl_root.relative_to(directory)) + all_models.update(graph.models) + all_metrics.update(graph.metrics) + all_parameters.update(graph.parameters) + except Exception as e: + _append_import_warning( + import_warnings, + code="tmdl_parse_error", + message=str(e), + source_format="TMDL", + source_file=str(tmdl_root.relative_to(directory)), + ) + logging.warning("Could not parse TMDL models in %s: %s", tmdl_root, e) + # Find and parse all files for file_path in directory.rglob("*"): if not file_path.is_file(): continue - if _try_load_python_file(file_path, directory, all_models): + if _try_load_python_file(file_path, directory, all_models, import_warnings): continue # Detect format and parse adapter = None suffix = file_path.suffix.lower() - if suffix == ".lkml": + if suffix == ".tmdl": + if tmdl_root: + continue + adapter = TMDLAdapter() + elif suffix == ".lkml": adapter = LookMLAdapter() elif suffix == ".malloy": from sidemantic.adapters.malloy import MalloyAdapter @@ -77,7 +116,7 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: adapter = YardstickAdapter(dialect=layer.dialect or "duckdb") else: # Sidemantic SQL files (pure SQL or with YAML frontmatter) - adapter = SidemanticAdapter() + adapter = SidemanticAdapter(lower_dax=False) elif suffix == ".json": content = file_path.read_text() if '"ldm"' in content and '"datasets"' in content: @@ -111,7 +150,7 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: adapter = CubeAdapter() # Check for Sidemantic native format (explicit models: key) elif "models:" in content: - adapter = SidemanticAdapter() + adapter = SidemanticAdapter(lower_dax=False) elif "metrics:" in content and "type: " in content: adapter = MetricFlowAdapter() elif "base_sql_table:" in content and "measures:" in content: @@ -138,10 +177,12 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: adapter = OmniAdapter() if adapter: + adapter_name = adapter.__class__.__name__.replace("Adapter", "") try: graph = adapter.parse(str(file_path)) + _merge_graph_passthrough_metadata(layer.graph, graph) + _extend_import_warnings(import_warnings, graph) # Track source format for each model - adapter_name = adapter.__class__.__name__.replace("Adapter", "") for model in graph.models.values(): if not hasattr(model, "_source_format"): model._source_format = adapter_name @@ -151,12 +192,21 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: all_metrics.update(graph.metrics) all_parameters.update(graph.parameters) except Exception as e: + _append_import_warning( + import_warnings, + code="adapter_parse_error", + message=str(e), + source_format=adapter_name, + source_file=str(file_path.relative_to(directory)), + ) # Skip files that fail to parse logging.warning("Could not parse %s: %s", file_path, e) # Infer cross-model relationships based on naming conventions _infer_relationships(all_models) + _lower_dax_models(all_models, all_metrics, all_parameters) + # Add all models to the layer (now with relationships) for model in all_models.values(): if model.name not in layer.graph.models: @@ -171,10 +221,25 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: if parameter.name not in layer.graph.parameters: layer.graph.add_parameter(parameter) + _merge_import_warnings(layer.graph, import_warnings) + # Rebuild adjacency graph to recognize all inferred relationships layer.graph.build_adjacency() +def _lower_dax_models(all_models: dict, all_metrics: dict, all_parameters: dict) -> None: + if not all_models and not all_metrics: + return + from sidemantic.core.semantic_graph import SemanticGraph + from sidemantic.dax.modeling import lower_dax_graph_expressions + + graph = SemanticGraph() + graph.models.update(all_models) + graph.metrics.update(all_metrics) + graph.parameters.update(all_parameters) + lower_dax_graph_expressions(graph) + + def _load_sml_directory(layer: "SemanticLayer", directory: Path, all_models: dict) -> None: """Parse an SML directory and load all models into the layer.""" from sidemantic.adapters.atscale_sml import AtScaleSMLAdapter @@ -264,7 +329,9 @@ def collect(candidate: object) -> None: return extracted -def _try_load_python_file(file_path: Path, directory: Path, all_models: dict) -> bool: +def _try_load_python_file( + file_path: Path, directory: Path, all_models: dict, import_warnings: list[dict[str, object]] +) -> bool: """Load semantic definitions from a Python file if it looks like Sidemantic code.""" if not _looks_like_python_semantic_definition(file_path): return False @@ -280,6 +347,13 @@ def _try_load_python_file(file_path: Path, directory: Path, all_models: dict) -> with captured_layer: namespace = runpy.run_path(str(file_path)) except Exception as e: + _append_import_warning( + import_warnings, + code="python_parse_error", + message=str(e), + source_format="Python", + source_file=str(file_path.relative_to(directory)), + ) logging.warning("Could not parse %s: %s", file_path, e) return False finally: @@ -334,6 +408,53 @@ def _try_load_sml(layer: "SemanticLayer", directory: Path, all_models: dict) -> return False +def _extend_import_warnings(target: list[dict[str, object]], graph: object) -> None: + warnings = getattr(graph, "import_warnings", None) + if not isinstance(warnings, list): + return + for warning in warnings: + if isinstance(warning, dict): + target.append(dict(warning)) + + +def _append_import_warning( + target: list[dict[str, object]], + *, + code: str, + message: str, + source_format: str, + source_file: str, + context: str = "loader", +) -> None: + target.append( + { + "code": code, + "context": context, + "source_format": source_format, + "source_file": source_file, + "message": message, + } + ) + + +def _merge_import_warnings(graph: object, warnings: list[dict[str, object]]) -> None: + existing = getattr(graph, "import_warnings", []) + merged: list[dict[str, object]] = [] + if isinstance(existing, list): + for warning in existing: + if isinstance(warning, dict): + merged.append(dict(warning)) + merged.extend(warnings) + graph.import_warnings = merged + + +def _merge_graph_passthrough_metadata(target_graph: object, source_graph: object) -> None: + for name, value in vars(source_graph).items(): + if not name.startswith("_tmdl_"): + continue + setattr(target_graph, name, copy.deepcopy(value)) + + def _infer_relationships(models: dict) -> None: """Infer relationships between models based on foreign key naming conventions. diff --git a/sidemantic/sql/generator.py b/sidemantic/sql/generator.py index 6f436ec7..3552ed59 100644 --- a/sidemantic/sql/generator.py +++ b/sidemantic/sql/generator.py @@ -5,7 +5,9 @@ import sqlglot from sqlglot import exp, select +from sidemantic.core.metric import Metric from sidemantic.core.preagg_matcher import PreAggregationMatcher +from sidemantic.core.relationship import RelationshipOverride from sidemantic.core.semantic_graph import SemanticGraph from sidemantic.core.symmetric_aggregate import build_symmetric_aggregate_sql from sidemantic.sql.aggregation_detection import sql_has_aggregate @@ -33,6 +35,64 @@ def __init__( self.dialect = dialect self.preagg_database = preagg_database self.preagg_schema = preagg_schema + self._generate_cache: dict[tuple[object, ...], str] = {} + self._identifier_sql_cache: dict[tuple[str, str], str] = {} + + def _freeze_for_cache(self, value): + """Convert request values into stable, hashable cache keys.""" + if isinstance(value, dict): + return tuple(sorted((str(k), self._freeze_for_cache(v)) for k, v in value.items())) + if isinstance(value, list | tuple): + return tuple(self._freeze_for_cache(item) for item in value) + if isinstance(value, set): + return tuple(sorted(self._freeze_for_cache(item) for item in value)) + try: + hash(value) + except TypeError: + return repr(value) + return value + + def _generate_cache_key( + self, + metrics, + dimensions, + filters, + segments, + order_by, + limit, + offset, + parameters, + ungrouped, + use_preaggregations, + aliases, + skip_default_time_dimensions, + relationship_overrides, + ) -> tuple[object, ...]: + return ( + getattr(self.graph, "_revision", 0), + self.dialect, + self.preagg_database, + self.preagg_schema, + self._freeze_for_cache(metrics or ()), + self._freeze_for_cache(dimensions or ()), + self._freeze_for_cache(filters or ()), + self._freeze_for_cache(segments or ()), + self._freeze_for_cache(order_by or ()), + limit, + offset, + self._freeze_for_cache(parameters or {}), + ungrouped, + use_preaggregations, + self._freeze_for_cache(aliases or {}), + skip_default_time_dimensions, + self._freeze_for_cache(relationship_overrides or ()), + ) + + def _cache_generated_sql(self, cache_key: tuple[object, ...], sql: str) -> str: + if len(self._generate_cache) >= 256: + self._generate_cache.pop(next(iter(self._generate_cache))) + self._generate_cache[cache_key] = sql + return sql def _date_trunc(self, granularity: str, column_expr: str) -> str: """Generate dialect-specific time truncation expression. @@ -201,9 +261,18 @@ def _quote_identifier(self, name: str) -> str: Delegates to sqlglot which handles reserved words (e.g., 'order') and special characters automatically. """ + cache_key = (self.dialect, name) + cached = self._identifier_sql_cache.get(cache_key) + if cached is not None: + return cached + if self._is_simple_identifier(name): - return sqlglot.to_identifier(name, quoted=False).sql(dialect=self.dialect) - return sqlglot.to_identifier(name, quoted=True).sql(dialect=self.dialect) + sql = sqlglot.to_identifier(name, quoted=False).sql(dialect=self.dialect) + else: + sql = sqlglot.to_identifier(name, quoted=True).sql(dialect=self.dialect) + + self._identifier_sql_cache[cache_key] = sql + return sql def _cte_name(self, model_name: str) -> str: """Get the CTE identifier name for a model.""" @@ -213,6 +282,107 @@ def _cte_ref(self, model_name: str, column_name: str) -> str: """Build a quoted reference to a CTE column.""" return f"{self._quote_identifier(self._cte_name(model_name))}.{self._quote_identifier(column_name)}" + def _metric_for_ref(self, metric_ref: str, model_context: str | None = None) -> tuple[Metric | None, str | None]: + if "." in metric_ref: + model_name, metric_name = metric_ref.split(".", 1) + try: + model = self.graph.get_model(model_name) + except KeyError: + model = None + if model is not None: + metric = model.get_metric(metric_name) + if metric is not None: + return metric, model_name + try: + return self.graph.get_metric(metric_ref), None + except KeyError: + return None, None + + if model_context: + try: + model = self.graph.get_model(model_context) + except KeyError: + model = None + if model is not None: + metric = model.get_metric(metric_ref) + if metric is not None: + return metric, model_context + + try: + return self.graph.get_metric(metric_ref), None + except KeyError: + pass + + matches = [] + for model_name, model in self.graph.models.items(): + metric = model.get_metric(metric_ref) + if metric is not None: + matches.append((metric, model_name)) + if len(matches) == 1: + return matches[0] + return None, None + + def _collect_relationship_overrides(self, metric_refs: list[str]) -> list[RelationshipOverride]: + overrides: list[RelationshipOverride] = [] + seen_overrides: set[tuple[str, str, str, str, str | None, str | None]] = set() + visited_metrics: set[tuple[str | None, str]] = set() + + def add_override(override: RelationshipOverride) -> None: + key = ( + override.from_model, + override.from_column, + override.to_model, + override.to_column, + override.join_type, + override.direction, + ) + if key in seen_overrides: + return + seen_overrides.add(key) + overrides.append(override) + + def visit_ref(metric_ref: str, model_context: str | None = None) -> None: + metric, owner_model = self._metric_for_ref(metric_ref, model_context) + if metric is None: + return + key = (owner_model, metric.name) + if key in visited_metrics: + return + visited_metrics.add(key) + + for override in metric.relationship_overrides or []: + add_override(override) + + if metric.type == "ratio": + if metric.numerator: + visit_ref(metric.numerator, owner_model) + if metric.denominator: + visit_ref(metric.denominator, owner_model) + elif metric.type == "derived" or (not metric.type and not metric.agg and metric.sql): + for dependency in metric.get_dependencies(self.graph, owner_model): + visit_ref(dependency, owner_model) + + for metric_ref in metric_refs: + visit_ref(metric_ref) + + return overrides + + @staticmethod + def _relationship_overrides_cache_key( + relationship_overrides: list[RelationshipOverride], + ) -> tuple[tuple[str, str, str, str, str | None, str | None], ...]: + return tuple( + ( + override.from_model, + override.from_column, + override.to_model, + override.to_column, + override.join_type, + override.direction, + ) + for override in relationship_overrides + ) + def _apply_default_time_dimensions(self, metrics: list[str], dimensions: list[str]) -> list[str]: """Auto-include default_time_dimension from models if not already present. @@ -339,6 +509,26 @@ def generate( segments = segments or [] parameters = parameters or {} aliases = aliases or {} + relationship_overrides = self._collect_relationship_overrides(metrics) + + cache_key = self._generate_cache_key( + metrics, + dimensions, + filters, + segments, + order_by, + limit, + offset, + parameters, + ungrouped, + use_preaggregations, + aliases, + skip_default_time_dimensions, + self._relationship_overrides_cache_key(relationship_overrides), + ) + cached = self._generate_cache.get(cache_key) + if cached is not None: + return cached # Auto-include default_time_dimension from metrics if not already present if not skip_default_time_dimensions: @@ -445,7 +635,8 @@ def metric_needs_window(m): needs_window_functions = any(metric_needs_window(m) for m in metrics) if needs_window_functions: - return self._generate_with_window_functions(metrics, dimensions, filters, order_by, limit, offset, aliases) + sql = self._generate_with_window_functions(metrics, dimensions, filters, order_by, limit, offset, aliases) + return self._cache_generated_sql(cache_key, sql) # Parse dimension references and extract granularities parsed_dims = self._parse_dimension_refs(dimensions) @@ -455,8 +646,8 @@ def metric_needs_window(m): # Check if we need symmetric aggregation (pre-aggregation approach) # This is needed when metrics come from different models at different join levels - if self._needs_preaggregation_for_fanout(metrics, dimensions): - return self._generate_with_preaggregation( + if self._needs_preaggregation_for_fanout(metrics, dimensions, relationship_overrides): + sql = self._generate_with_preaggregation( metrics=metrics, dimensions=dimensions, filters=filters, @@ -466,6 +657,7 @@ def metric_needs_window(m): offset=offset, aliases=aliases, ) + return self._cache_generated_sql(cache_key, sql) # Try to use pre-aggregation if enabled (single model queries only) if use_preaggregations and len(model_names) == 1 and not ungrouped: @@ -483,7 +675,7 @@ def metric_needs_window(m): instrumentation = self._generate_instrumentation_comment( models=[model_names[0]], metrics=metrics, dimensions=dimensions, used_preagg=True ) - return preagg_sql + "\n" + instrumentation + return self._cache_generated_sql(cache_key, preagg_sql + "\n" + instrumentation) if not model_names: raise ValueError("No models found for query") @@ -497,7 +689,7 @@ def metric_needs_window(m): for model_b in list(model_names)[i + 1 :]: # Find join path and add intermediate models try: - join_path = self.graph.find_relationship_path(model_a, model_b) + join_path = self.graph.find_relationship_path(model_a, model_b, relationship_overrides) for jp in join_path: all_models.add(jp.from_model) all_models.add(jp.to_model) @@ -566,6 +758,7 @@ def metric_needs_window(m): order_by=order_by, all_models=all_models, metric_filter_columns=metric_filter_cols, + relationship_overrides=relationship_overrides, ) cte_sqls.append(cte_sql) @@ -582,6 +775,7 @@ def metric_needs_window(m): offset=offset, ungrouped=ungrouped, aliases=aliases, + relationship_overrides=relationship_overrides, ) # Combine CTEs and main query @@ -598,7 +792,7 @@ def metric_needs_window(m): ) full_sql = full_sql + "\n" + instrumentation - return full_sql + return self._cache_generated_sql(cache_key, full_sql) def _parse_dimension_refs(self, dimensions: list[str]) -> list[tuple[str, str | None]]: """Parse dimension references to extract granularities. @@ -717,37 +911,70 @@ def add_model(model_name: str): models.append(model_name) seen.add(model_name) - def collect_models_from_metric(metric_ref: str): - """Recursively collect models needed from a metric.""" + def collect_models_from_metric_object(metric: Metric, model_context: str | None, visited: set[str]): + metric_key = f"{model_context or ''}.{metric.name}" + if metric_key in visited: + return + visited.add(metric_key) + + for required_model in metric.required_models or []: + add_model(required_model) + + for override in metric.relationship_overrides or []: + add_model(override.from_model) + add_model(override.to_model) + + if metric.type == "ratio": + if metric.numerator: + collect_models_from_metric(metric.numerator, model_context, visited) + if metric.denominator: + collect_models_from_metric(metric.denominator, model_context, visited) + elif metric.type == "derived" or (not metric.type and not metric.agg and metric.sql): + for ref_metric in metric.get_dependencies(self.graph, model_context): + collect_models_from_metric(ref_metric, model_context, visited) + if metric.sql: + for model_name in self._extract_models_from_sql(metric.sql): + add_model(model_name) + elif metric.agg and metric.sql: + for model_name in self._extract_models_from_sql(metric.sql): + add_model(model_name) + + def collect_models_from_metric_with_visited(metric_ref: str, model_context: str | None, visited: set[str]): + """Recursively collect models needed from a metric with cycle protection.""" if "." in metric_ref: # Direct measure reference (model.measure) - add_model(metric_ref.split(".")[0]) + model_name, measure_name = metric_ref.split(".", 1) + add_model(model_name) + try: + model = self.graph.get_model(model_name) + except KeyError: + model = None + if model: + metric = model.get_metric(measure_name) + if metric: + collect_models_from_metric_object(metric, model_name, visited) else: # It's a metric, need to resolve its dependencies try: metric = self.graph.get_metric(metric_ref) if metric: - if metric.type == "ratio": - if metric.numerator: - collect_models_from_metric(metric.numerator) - if metric.denominator: - collect_models_from_metric(metric.denominator) - elif metric.type == "derived" or (not metric.type and not metric.agg and metric.sql): - # Derived or untyped metrics with sql - auto-detect dependencies - for ref_metric in metric.get_dependencies(self.graph): - collect_models_from_metric(ref_metric) - # Inline SQL expression metrics (e.g., SUM(orders.amount)) - # can have empty dependencies, so also parse model refs directly. - if metric.sql: - for model_name in self._extract_models_from_sql(metric.sql): - add_model(model_name) - elif metric.agg and metric.sql: - # Graph-level simple aggregations can qualify fields - # (e.g., SUM(orders.amount)); include those models. - for model_name in self._extract_models_from_sql(metric.sql): - add_model(model_name) + collect_models_from_metric_object(metric, model_context, visited) except KeyError: - pass + if model_context: + try: + model = self.graph.get_model(model_context) + except KeyError: + model = None + if model: + metric = model.get_metric(metric_ref) + if metric: + collect_models_from_metric_object(metric, model_context, visited) + + def collect_models_from_metric( + metric_ref: str, model_context: str | None = None, visited: set[str] | None = None + ): + """Recursively collect models needed from a metric.""" + collect_models_from_metric_with_visited(metric_ref, model_context, visited or set()) # Collect from dimensions first (since they define the grain) for dim in dimensions: @@ -1086,6 +1313,7 @@ def _build_model_cte( order_by: list[str] | None = None, all_models: set[str] | None = None, metric_filter_columns: set[str] | None = None, + relationship_overrides: list[RelationshipOverride] | None = None, ) -> str: """Build CTE SQL for a model with optional filter pushdown. @@ -1097,6 +1325,8 @@ def _build_model_cte( order_by: Order by fields (for determining needed dimensions) all_models: All models in query (for determining if joins needed) metric_filter_columns: Columns needed for metric-level filters + relationship_overrides: Query-local relationship edges that may need + additional join key columns in this CTE Returns: CTE SQL string @@ -1104,6 +1334,7 @@ def _build_model_cte( model = self.graph.get_model(model_name) all_models = all_models or {model_name} needs_joins = len(all_models) > 1 + relationship_overrides = relationship_overrides or [] # Find which dimensions are actually needed needed_dimensions = self._find_needed_dimensions( @@ -1164,6 +1395,17 @@ def _build_model_cte( select_cols.append(f"{self._quote_identifier(fk)} AS {self._quote_alias(fk)}") columns_added.add(fk) + for override in relationship_overrides: + if override.from_model == model_name: + join_key = override.from_column + elif override.to_model == model_name: + join_key = override.to_column + else: + continue + if join_key not in columns_added: + select_cols.append(f"{join_key} AS {self._quote_alias(join_key)}") + columns_added.add(join_key) + # Determine table alias for {model} placeholder replacement # In CTEs, we're selecting from the raw table (or subquery AS t) model_table_alias = "t" if model.sql else "" @@ -1411,7 +1653,12 @@ def collect_measures_from_metric(metric_ref: str, visited: set[str] | None = Non return cte_sql - def _has_fanout_joins(self, base_model_name: str, other_models: list[str]) -> dict[str, bool]: + def _has_fanout_joins( + self, + base_model_name: str, + other_models: list[str], + relationship_overrides: list[RelationshipOverride] | None = None, + ) -> dict[str, bool]: """Determine which models need symmetric aggregates due to fan-out. When one-to-many joins exist from the base model, measures from @@ -1432,7 +1679,11 @@ def _has_fanout_joins(self, base_model_name: str, other_models: list[str]) -> di for other_model in other_models: try: - join_path = self.graph.find_relationship_path(base_model_name, other_model) + join_path = self.graph.find_relationship_path( + base_model_name, + other_model, + relationship_overrides, + ) if not join_path: continue # Check all hops: any one_to_many in the path creates fan-out @@ -1460,7 +1711,12 @@ def _has_fanout_joins(self, base_model_name: str, other_models: list[str]) -> di return needs_symmetric - def _needs_preaggregation_for_fanout(self, metrics: list[str], dimensions: list[str]) -> bool: + def _needs_preaggregation_for_fanout( + self, + metrics: list[str], + dimensions: list[str], + relationship_overrides: list[RelationshipOverride] | None = None, + ) -> bool: """Determine if pre-aggregation is needed to avoid fan-out. Pre-aggregation is needed when: @@ -1501,7 +1757,7 @@ def _needs_preaggregation_for_fanout(self, metrics: list[str], dimensions: list[ for model_b in metric_model_list[i + 1 :]: try: # Check path from A to B - join_path = self.graph.find_relationship_path(model_a, model_b) + join_path = self.graph.find_relationship_path(model_a, model_b, relationship_overrides) if join_path: # If any hop is many_to_one (from A's perspective), model_a metrics # would be replicated when joining to model_b @@ -1512,7 +1768,7 @@ def _needs_preaggregation_for_fanout(self, metrics: list[str], dimensions: list[ return True # Check reverse path - join_path_reverse = self.graph.find_relationship_path(model_b, model_a) + join_path_reverse = self.graph.find_relationship_path(model_b, model_a, relationship_overrides) if join_path_reverse: for jp in join_path_reverse: if jp.relationship == "many_to_one": @@ -1533,6 +1789,7 @@ def _generate_with_preaggregation( limit: int | None = None, offset: int | None = None, aliases: dict[str, str] | None = None, + relationship_overrides: list[RelationshipOverride] | None = None, ) -> str: """Generate SQL using pre-aggregation to avoid fan-out. @@ -1759,6 +2016,7 @@ def _build_main_select( offset: int | None = None, ungrouped: bool = False, aliases: dict[str, str] | None = None, + relationship_overrides: list[RelationshipOverride] | None = None, ) -> str: """Build main SELECT using SQLGlot builder API. @@ -1774,13 +2032,14 @@ def _build_main_select( offset: Row offset ungrouped: If True, return raw rows without aggregation aliases: Custom aliases for fields (dict mapping field reference to alias) + relationship_overrides: Query-local relationship edges for pathing Returns: SQL SELECT statement """ aliases = aliases or {} # Detect if symmetric aggregates are needed - symmetric_agg_needed = self._has_fanout_joins(base_model_name, other_models) + symmetric_agg_needed = self._has_fanout_joins(base_model_name, other_models, relationship_overrides) # Check for dimension/metric name collisions across models # If there are collisions, prefix with model name @@ -1921,7 +2180,7 @@ def _build_main_select( joined_models = {base_model_name} for other_model in other_models: - join_path = self.graph.find_relationship_path(base_model_name, other_model) + join_path = self.graph.find_relationship_path(base_model_name, other_model, relationship_overrides) if join_path: # Apply each join in the path for jp in join_path: @@ -1944,7 +2203,7 @@ def _build_main_select( join_cond = " AND ".join(join_conditions) # Use INNER JOIN if this model has filters applied, otherwise LEFT JOIN - join_type = "inner" if jp.to_model in models_with_filters else "left" + join_type = jp.join_type_override or ("inner" if jp.to_model in models_with_filters else "left") query = query.join(right_table, on=join_cond, join_type=join_type) joined_models.add(jp.to_model) diff --git a/tests/adapters/tmdl/test_external_tmdl_fixtures.py b/tests/adapters/tmdl/test_external_tmdl_fixtures.py new file mode 100644 index 00000000..03cef924 --- /dev/null +++ b/tests/adapters/tmdl/test_external_tmdl_fixtures.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path + +import pytest + +from sidemantic.adapters.tmdl import TMDLAdapter +from sidemantic.core.introspection import describe_graph + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE_ROOT = ROOT / "fixtures" / "external_powerbi" + +TMDL_FIXTURES = [ + pytest.param( + "microsoft-analysis-services-sales", 11, 29, 5, 1, {"dax_translation_fallback": 2}, id="analysis-services" + ), + pytest.param("microsoft-fabric-samples-bank-customer-churn", 1, 4, 0, 0, {}, id="fabric-samples"), + pytest.param("pbi-tools-adventureworks-dw2020", 7, 0, 8, 2, {}, id="adventureworks"), + pytest.param("pbip-lineage-explorer-sample", 6, 7, 3, 0, {}, id="pbip-lineage"), + pytest.param( + "ruiromano-pbip-demo-agentic-model01", 4, 15, 4, 1, {"dax_translation_fallback": 2}, id="pbip-demo-agentic" + ), +] + + +@pytest.mark.parametrize( + ("fixture_name", "expected_models", "expected_metrics", "expected_relationships", "expected_inactive", "warnings"), + TMDL_FIXTURES, +) +def test_external_powerbi_tmdl_fixtures_parse( + fixture_name: str, + expected_models: int, + expected_metrics: int, + expected_relationships: int, + expected_inactive: int, + warnings: dict[str, int], +): + graph = TMDLAdapter().parse(FIXTURE_ROOT / fixture_name) + + assert len(graph.models) == expected_models + assert sum(len(model.metrics) for model in graph.models.values()) == expected_metrics + assert sum(len(model.relationships) for model in graph.models.values()) == expected_relationships + assert ( + sum(1 for model in graph.models.values() for rel in model.relationships if not rel.active) == expected_inactive + ) + assert Counter(warning["code"] for warning in getattr(graph, "import_warnings", [])) == warnings + + description = describe_graph(graph) + json.dumps(description) + assert {model["source_format"] for model in description["models"]} == {"TMDL"} + + +@pytest.mark.parametrize("fixture_name", [fixture.values[0] for fixture in TMDL_FIXTURES]) +def test_external_powerbi_fixtures_include_upstream_license(fixture_name: str): + license_text = (FIXTURE_ROOT / fixture_name / "LICENSE.upstream").read_text() + assert "MIT License" in license_text diff --git a/tests/adapters/tmdl/test_parser.py b/tests/adapters/tmdl/test_parser.py new file mode 100644 index 00000000..dd38fbc0 --- /dev/null +++ b/tests/adapters/tmdl/test_parser.py @@ -0,0 +1,194 @@ +"""Tests for TMDL parser.""" + +import textwrap + +import pytest + +from sidemantic.adapters.tmdl_parser import TmdlExpression, TmdlParseError, TmdlParser, merge_documents + + +def test_parser_description_and_meta(): + """Descriptions and meta blocks are parsed.""" + tmdl = textwrap.dedent( + """ + /// Model description line + model 'Sales Model' + expression Server = "localhost" meta [IsParameterQuery=true, Type="Text"] + """ + ) + + parser = TmdlParser() + doc = parser.parse(tmdl) + model = doc.nodes[0] + assert model.description == "Model description line" + + expr = next(child for child in model.children if child.type == "expression") + assert isinstance(expr.default_property, TmdlExpression) + assert expr.default_property.text == '"localhost"' + assert expr.default_property.meta["IsParameterQuery"] is True + assert expr.default_property.meta["Type"] == "Text" + + +def test_parser_backtick_block(): + """Backtick expressions preserve text blocks.""" + tmdl = textwrap.dedent( + """ + table test + measure revenue = ``` + SUM('test'[amount]) + ``` + """ + ) + + parser = TmdlParser() + doc = parser.parse(tmdl) + table = doc.nodes[0] + measure = next(child for child in table.children if child.type == "measure") + assert isinstance(measure.default_property, TmdlExpression) + assert measure.default_property.text == "SUM('test'[amount])" + assert measure.default_property.block_delimiter == "```" + + +def test_parser_preserves_leading_comments_on_object(): + tmdl = textwrap.dedent( + """ + # file comment + table test + column id + dataType: int64 + """ + ) + + parser = TmdlParser() + doc = parser.parse(tmdl) + table = doc.nodes[0] + assert table.leading_comments == ["# file comment"] + + +def test_parser_preserves_leading_comments_on_dedented_sibling_object(): + tmdl = textwrap.dedent( + """ + table test + # first child comment + column id + dataType: int64 + // second child comment + measure revenue = SUM('test'[amount]) + """ + ) + + parser = TmdlParser() + doc = parser.parse(tmdl) + table = doc.nodes[0] + column = next(child for child in table.children if child.type == "column") + measure = next(child for child in table.children if child.type == "measure") + assert column.leading_comments == ["# first child comment"] + assert measure.leading_comments == ["// second child comment"] + + +def test_parser_preserves_leading_comments_on_root_sibling_object(): + tmdl = textwrap.dedent( + """ + table sales + column id + dataType: int64 + // relationship comment + relationship sales_products + fromColumn: sales[id] + toColumn: products[id] + fromCardinality: many + toCardinality: one + """ + ) + + parser = TmdlParser() + doc = parser.parse(tmdl) + relationship = next(node for node in doc.nodes if node.type == "relationship") + assert relationship.leading_comments == ["// relationship comment"] + + +def test_parser_allows_unindented_blank_lines_between_child_objects(): + tmdl = textwrap.dedent( + """ + table test + column id + dataType: int64 + + measure revenue = SUM('test'[amount]) + """ + ) + + parser = TmdlParser() + doc = parser.parse(tmdl) + table = doc.nodes[0] + assert any(child.type == "column" for child in table.children) + assert any(child.type == "measure" for child in table.children) + + +def test_parser_backtick_block_unterminated_raises_parse_error(): + """Unterminated backtick expressions raise a typed parser error with location.""" + tmdl = textwrap.dedent( + """ + table test + measure revenue = ``` + SUM('test'[amount]) + """ + ) + + parser = TmdlParser() + with pytest.raises(TmdlParseError, match="Unterminated backtick expression block") as exc_info: + parser.parse(tmdl, file="bad.tmdl") + + assert exc_info.value.location is not None + assert exc_info.value.location.file == "bad.tmdl" + assert exc_info.value.location.line == 3 + assert exc_info.value.location.column == 5 + + +def test_parser_create_or_replace(): + """createOrReplace scripts parse into a root node.""" + tmdl = textwrap.dedent( + """ + createOrReplace + table test + column id + dataType: int64 + """ + ) + + parser = TmdlParser() + doc = parser.parse(tmdl) + root = doc.nodes[0] + assert root.type.lower() == "createorreplace" + table = root.children[0] + assert table.type == "table" + + +def test_parser_merge_partial_declarations(): + """Partial declarations merge without losing properties.""" + part1 = textwrap.dedent( + """ + table test + column id + dataType: int64 + """ + ) + part2 = textwrap.dedent( + """ + table test + measure count = COUNTROWS('test') + """ + ) + + parser = TmdlParser() + doc1 = parser.parse(part1, file="part1.tmdl") + doc2 = parser.parse(part2, file="part2.tmdl") + + merged = merge_documents([doc1, doc2]) + table = merged[0] + assert any(child.type == "column" for child in table.children) + assert any(child.type == "measure" for child in table.children) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/adapters/tmdl/test_parsing.py b/tests/adapters/tmdl/test_parsing.py new file mode 100644 index 00000000..d1777d5e --- /dev/null +++ b/tests/adapters/tmdl/test_parsing.py @@ -0,0 +1,2716 @@ +"""Tests for TMDL adapter.""" + +import difflib +import json +import tempfile +import textwrap +from pathlib import Path + +import pytest + +import sidemantic.adapters.tmdl as tmdl_module +from sidemantic import SemanticLayer +from sidemantic.adapters.tmdl import TMDLAdapter +from sidemantic.core.dimension import Dimension +from sidemantic.core.introspection import describe_graph +from sidemantic.core.metric import Metric +from sidemantic.core.model import Model +from sidemantic.core.relationship import Relationship +from sidemantic.core.semantic_graph import SemanticGraph +from sidemantic.loaders import load_from_directory + +# ============================================================================= +# BASIC PARSING TESTS +# ============================================================================= + + +def test_import_tmdl_directory(): + """Test importing a TMDL folder structure.""" + adapter = TMDLAdapter() + graph = adapter.parse("tests/fixtures/tmdl") + + assert "Sales" in graph.models + assert "Products" in graph.models + + sales = graph.models["Sales"] + products = graph.models["Products"] + + assert sales.description == "Sales fact table" + assert products.description == "Product dimension" + + assert sales.primary_key == "Sale ID" + assert sales.default_time_dimension == "Order Date" + assert sales.default_grain == "day" + + order_date = sales.get_dimension("Order Date") + assert order_date.type == "time" + assert order_date.granularity == "day" + + amount = sales.get_dimension("Amount") + assert amount.type == "numeric" + assert amount.format == "$#,##0.00" + + total_sales = sales.get_metric("Total Sales") + assert total_sales.agg == "sum" + assert total_sales.sql == "Amount" + assert total_sales.format == "$#,##0.00" + + sales_ly = sales.get_metric("Sales LY") + assert sales_ly.type == "time_comparison" + assert sales_ly.base_metric == "Sales.Total Sales" + assert sales_ly.comparison_type == "yoy" + assert sales_ly.calculation == "previous_value" + + backtick = sales.get_metric("Backtick Measure") + assert backtick.agg == "sum" + + rel = next(r for r in sales.relationships if r.name == "Products") + assert rel.type == "many_to_one" + assert rel.foreign_key == "Product Key" + assert rel.primary_key == "Product Key" + + +def test_import_tmdl_directory_does_not_warn_for_model_relationship_refs(): + adapter = TMDLAdapter() + graph = adapter.parse("tests/fixtures/tmdl") + + warnings = getattr(graph, "import_warnings", []) + relationship_warnings = [ + warning + for warning in warnings + if warning.get("code") == "relationship_parse_skip" and warning.get("context") == "relationship" + ] + assert relationship_warnings == [] + + +def test_tmdl_export_preserves_model_ref_table_literals_and_order(): + graph = TMDLAdapter().parse("tests/fixtures/tmdl") + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + model_file = Path(tmpdir) / "definition" / "model.tmdl" + content = model_file.read_text() + sales_ref = " ref table 'Sales'" + products_ref = " ref table 'Products'" + assert sales_ref in content + assert products_ref in content + assert content.index(sales_ref) < content.index(products_ref) + + +def test_tmdl_export_preserves_backtick_measure_expression_delimiters(): + graph = TMDLAdapter().parse("tests/fixtures/tmdl") + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert "measure 'Backtick Measure' = ```" in content + assert "\n SUM('Sales'[Amount])\n ```" in content + + +def test_tmdl_export_preserves_imported_column_core_property_order(): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + formatString: "$#,##0.00" + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert content.index("sourceColumn: Amount") < content.index('formatString: "$#,##0.00"') + + +def test_tmdl_export_preserves_imported_measure_core_property_order(): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + formatString: "0.00" + description: "Revenue Desc" + caption: "Revenue Label" + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert content.index('formatString: "0.00"') < content.index('description: "Revenue Desc"') + assert content.index('description: "Revenue Desc"') < content.index('caption: "Revenue Label"') + + +def test_tmdl_export_preserves_table_leading_comments(): + graph = TMDLAdapter().parse("tests/fixtures/tmdl") + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert content.startswith("# comment that should be ignored\n") + + +def test_tmdl_export_preserves_column_and_measure_leading_comments(): + tmdl = textwrap.dedent( + """ + table Sales + # Amount column comment + column Amount + dataType: decimal + sourceColumn: Amount + // Revenue measure comment + measure Revenue = SUM(Sales[Amount]) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert " # Amount column comment\n column Amount" in content + assert " // Revenue measure comment\n measure Revenue = SUM(Sales[Amount])" in content + + +def test_tmdl_fixture_definition_roundtrip_is_byte_stable(): + graph = TMDLAdapter().parse("tests/fixtures/tmdl") + + fixture_root = Path("tests/fixtures/tmdl/definition") + fixture_files = sorted(path.relative_to(fixture_root) for path in fixture_root.rglob("*.tmdl")) + assert fixture_files, "Expected fixture TMDL files" + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + export_root = Path(tmpdir) / "definition" + export_files = sorted(path.relative_to(export_root) for path in export_root.rglob("*.tmdl")) + assert export_files == fixture_files + + for rel_path in fixture_files: + fixture_text = (fixture_root / rel_path).read_text() + export_text = (export_root / rel_path).read_text() + if export_text != fixture_text: + diff = "\n".join( + difflib.unified_diff( + fixture_text.splitlines(), + export_text.splitlines(), + fromfile=f"fixture/{rel_path}", + tofile=f"export/{rel_path}", + lineterm="", + ) + ) + raise AssertionError(f"Roundtrip mismatch for {rel_path}:\n{diff}") + + +def test_tmdl_realistic_fixture_import_export_contract(tmp_path): + fixture_root = Path("tests/fixtures/tmdl_realistic") + graph = TMDLAdapter().parse(fixture_root) + + assert getattr(graph, "import_warnings") == [] + assert set(graph.models) == {"Sales", "Products", "Calendar", "Sales By Category"} + + sales = graph.models["Sales"] + assert sales.description == "Sales fact table" + assert sales.get_dimension("Amount x2").dax == "Sales[Amount] * 2" + assert sales.get_dimension("Amount x2").sql == "(Amount * 2)" + assert sales.get_metric("Total Sales").dax == "SUM(Sales[Amount])" + assert sales.get_metric("Total Sales").sql == "Amount" + assert getattr(sales, "_tmdl_child_nodes")[0].name == "TableTag" + + calculated = graph.models["Sales By Category"] + assert calculated.sql == ( + "SELECT Products.Category, SUM(Sales.Amount) AS Revenue FROM Products " + "LEFT JOIN Sales ON Products.ProductKey = Sales.ProductKey GROUP BY Products.Category" + ) + assert getattr(calculated, "_dax_required_models") == ["Products", "Sales"] + assert getattr(calculated, "_tmdl_child_nodes")[0].name == "CalculationTag" + + sales_products = next(rel for rel in sales.relationships if rel.name == "Products") + assert getattr(sales_products, "_tmdl_child_nodes")[0].name == "RelationshipLineage" + + description = describe_graph(graph, model_names=["Sales", "Sales By Category"]) + json.dumps(description) + sales_info = next(model for model in description["models"] if model["name"] == "Sales") + calculated_info = next(model for model in description["models"] if model["name"] == "Sales By Category") + products_rel = next(rel for rel in sales_info["relationships"] if rel["name"] == "Products") + total_sales = next(metric for metric in sales_info["metrics"] if metric["name"] == "Total Sales") + assert sales_info["source_format"] == "TMDL" + assert products_rel["tmdl"]["child_nodes"][0]["name"] == "RelationshipLineage" + assert total_sales["faithful_lowering"] is True + assert calculated_info["kind"] == "calculated_table" + assert calculated_info["tmdl"]["child_nodes"][0]["name"] == "CalculationTag" + + layer = SemanticLayer() + load_from_directory(layer, fixture_root) + export_dir = tmp_path / "exported" + TMDLAdapter().export(layer.graph, export_dir) + + fixture_definition_root = fixture_root / "definition" + export_definition_root = export_dir / "definition" + fixture_files = sorted( + path.relative_to(fixture_definition_root) for path in fixture_definition_root.rglob("*.tmdl") + ) + export_files = sorted(path.relative_to(export_definition_root) for path in export_definition_root.rglob("*.tmdl")) + assert export_files == fixture_files + + reparsed_graph = TMDLAdapter().parse(export_dir) + assert getattr(reparsed_graph, "import_warnings") == [] + assert set(reparsed_graph.models) == set(graph.models) + reparsed_sales = reparsed_graph.models["Sales"] + reparsed_calculated = reparsed_graph.models["Sales By Category"] + reparsed_rel = next(rel for rel in reparsed_sales.relationships if rel.name == "Products") + assert reparsed_sales.get_dimension("Amount x2").dax == "Sales[Amount] * 2" + assert reparsed_sales.get_metric("Total Sales").dax == "SUM(Sales[Amount])" + assert getattr(reparsed_calculated, "_tmdl_child_nodes")[0].name == "CalculationTag" + assert getattr(reparsed_rel, "_tmdl_child_nodes")[0].name == "RelationshipLineage" + assert getattr(reparsed_rel, "_tmdl_from_column") == "ProductKey" + assert getattr(reparsed_rel, "_tmdl_to_column") == "ProductKey" + + database_content = (export_dir / "definition" / "database.tmdl").read_text() + model_content = (export_dir / "definition" / "model.tmdl").read_text() + sales_content = (export_dir / "definition" / "tables" / "Sales.tmdl").read_text() + assert (export_dir / "definition" / "tables" / "Sales By Category.tmdl").is_file() + assert not (export_dir / "definition" / "tables" / "Sales_By_Category.tmdl").exists() + calculated_content = (export_dir / "definition" / "tables" / "Sales By Category.tmdl").read_text() + relationship_content = (export_dir / "definition" / "relationships.tmdl").read_text() + + assert "database 'Retail Analytics'" in database_content + assert "compatibilityLevel: 1601" in database_content + assert "annotation DatabaseTag" in database_content + assert "perspective Executive" in model_content + assert "culture en-US" in model_content + assert "role 'Sales Managers'" in model_content + assert "partition Sales = m" in sales_content + assert "Sql.Database" in sales_content + assert "annotation CalculationTag" in calculated_content + assert "annotation RelationshipLineage" in relationship_content + + +def test_tmdl_calculated_table_multitable_summarizecolumns(): + tmdl = textwrap.dedent( + """ + table Sales + column SaleID + dataType: int64 + isKey + sourceColumn: SaleID + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column Amount + dataType: decimal + sourceColumn: Amount + table Products + column ProductKey + dataType: int64 + isKey + sourceColumn: ProductKey + column Category + dataType: string + sourceColumn: Category + calculatedTable SalesByCategory = SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount])) + relationship SalesProducts + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + adapter = TMDLAdapter() + graph = adapter.parse(temp_path) + model = graph.models["SalesByCategory"] + + assert model.table is None + assert model.sql is not None + assert "LEFT JOIN" in model.sql + assert "Products.ProductKey" in model.sql + assert "Sales.ProductKey" in model.sql + assert "GROUP BY Products.Category" in model.sql + + description = describe_graph(graph) + model_info = next(item for item in description["models"] if item["name"] == "SalesByCategory") + assert model_info["kind"] == "calculated_table" + assert model_info["calculated_table"] is True + assert ( + model_info["original_expression"] == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' + ) + assert model_info["dax_lowered"] is True + assert model_info["dax_required_models"] == ["Products", "Sales"] + finally: + temp_path.unlink() + + +def test_tmdl_parses_dax_ast_when_available(): + """Ensure DAX AST is attached when sidemantic_dax is installed.""" + try: + import sidemantic_dax + import sidemantic_dax.ast as dax_ast + except Exception: + pytest.skip("sidemantic_dax not installed") + + try: + sidemantic_dax.parse_expression("1") + except RuntimeError as exc: + if "native module is not available" in str(exc): + pytest.skip("sidemantic_dax native module not available") + raise + + adapter = TMDLAdapter() + graph = adapter.parse("tests/fixtures/tmdl") + + total_sales = graph.models["Sales"].get_metric("Total Sales") + assert total_sales.dax == "SUM('Sales'[Amount])" + assert isinstance(total_sales._dax_ast, dax_ast.FunctionCall) + + +# ============================================================================= +# TYPE AND MEASURE MAPPING TESTS +# ============================================================================= + + +def test_tmdl_column_type_mapping(): + """Test TMDL column data types map to sidemantic types.""" + tmdl = textwrap.dedent( + """ + table test + column status + dataType: string + column is_active + dataType: boolean + column amount + dataType: decimal + column event_date + dataType: date + column created_at + dataType: dateTime + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + adapter = TMDLAdapter() + graph = adapter.parse(temp_path) + model = graph.models["test"] + + assert model.get_dimension("status").type == "categorical" + assert model.get_dimension("is_active").type == "boolean" + assert model.get_dimension("amount").type == "numeric" + assert model.get_dimension("event_date").type == "time" + assert model.get_dimension("event_date").granularity == "day" + assert model.get_dimension("created_at").granularity == "hour" + finally: + temp_path.unlink() + + +def test_tmdl_measure_aggregation_mapping(): + """Test simple DAX measures map to sidemantic aggregations.""" + tmdl = textwrap.dedent( + """ + table test + column amount + dataType: decimal + column user_id + dataType: int64 + measure total_amount = SUM('test'[amount]) + measure distinct_users = DISTINCTCOUNT('test'[user_id]) + measure row_count = COUNTROWS('test') + measure median_amount = MEDIAN('test'[amount]) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + adapter = TMDLAdapter() + graph = adapter.parse(temp_path) + model = graph.models["test"] + + total_amount = model.get_metric("total_amount") + assert total_amount.agg == "sum" + assert total_amount.sql == "amount" + + distinct_users = model.get_metric("distinct_users") + assert distinct_users.agg == "count_distinct" + assert distinct_users.sql == "user_id" + + row_count = model.get_metric("row_count") + assert row_count.agg == "count" + assert row_count.sql is None + + median_amount = model.get_metric("median_amount") + assert median_amount.agg == "median" + assert median_amount.sql == "amount" + finally: + temp_path.unlink() + + +def test_tmdl_measure_derived_expression(): + """Test complex DAX measures are treated as derived.""" + tmdl = textwrap.dedent( + """ + table test + column amount + dataType: decimal + column quantity + dataType: int64 + measure avg_price = SUM('test'[amount]) / SUM('test'[quantity]) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + adapter = TMDLAdapter() + graph = adapter.parse(temp_path) + metric = graph.models["test"].get_metric("avg_price") + assert metric.type == "derived" + assert "SUM" in metric.sql + finally: + temp_path.unlink() + + +def test_tmdl_measure_time_comparison_with_inline_aggregate_base(): + tmdl = textwrap.dedent( + """ + table 'Sales' + column Amount + dataType: decimal + sourceColumn: Amount + column 'Order Date' + dataType: date + sourceColumn: OrderDate + measure 'Sales LY Inline' = CALCULATE(SUM('Sales'[Amount]), SAMEPERIODLASTYEAR('Sales'[Order Date])) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + adapter = TMDLAdapter() + graph = adapter.parse(temp_path) + model = graph.models["Sales"] + + sales_ly_inline = model.get_metric("Sales LY Inline") + assert sales_ly_inline.type == "time_comparison" + assert sales_ly_inline.base_metric == "Sales.__sales_ly_inline_base" + assert sales_ly_inline.comparison_type == "yoy" + assert sales_ly_inline.calculation == "previous_value" + + inline_base = model.get_metric("__sales_ly_inline_base") + assert inline_base.agg == "sum" + assert inline_base.sql == "Amount" + finally: + temp_path.unlink() + + +def test_tmdl_measure_totalytd_preserves_filters(): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column OrderDate + dataType: date + sourceColumn: OrderDate + measure SalesYTDFiltered = TOTALYTD(CALCULATE(SUM(Sales[Amount]), Sales[ProductKey] = 1), Sales[OrderDate]) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + adapter = TMDLAdapter() + graph = adapter.parse(temp_path) + metric = graph.models["Sales"].get_metric("SalesYTDFiltered") + assert metric.type == "cumulative" + assert metric.grain_to_date == "year" + assert metric.agg == "sum" + assert metric.sql == "Amount" + assert metric.filters == ["(ProductKey = 1)"] + finally: + temp_path.unlink() + + +def test_tmdl_import_many_to_many_relationship_preserves_join_keys(): + tmdl = textwrap.dedent( + """ + table Sales + column SalesKey + dataType: int64 + isKey + sourceColumn: SalesKey + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + isKey + sourceColumn: ProductKey + column SalesKey + dataType: int64 + sourceColumn: SalesKey + relationship SalesProductsMany + fromColumn: Sales[ProductKey] + toColumn: Products[SalesKey] + fromCardinality: many + toCardinality: many + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + adapter = TMDLAdapter() + graph = adapter.parse(temp_path) + rel = graph.models["Sales"].relationships[0] + assert rel.type == "many_to_many" + assert rel.foreign_key == "ProductKey" + assert rel.primary_key == "SalesKey" + finally: + temp_path.unlink() + + +def test_tmdl_import_collects_dax_translation_fallback_warnings(monkeypatch): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + calculatedColumn BadColumn = BADFUNC(Sales[Amount]) + measure BadMeasure = BADFUNC(Sales[Amount]) + calculatedTable BadTable = BADTABLE(Sales) + """ + ) + + monkeypatch.setattr(tmdl_module, "_parse_dax_expression", lambda expression, node, context: object()) + monkeypatch.setattr( + tmdl_module, + "translate_dax_scalar", + lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("scalar unsupported")), + ) + monkeypatch.setattr( + tmdl_module, + "translate_dax_metric", + lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("metric unsupported")), + ) + monkeypatch.setattr( + tmdl_module, + "translate_dax_table", + lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("table unsupported")), + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + warnings = getattr(graph, "import_warnings") + assert len(warnings) == 3 + assert {warning["context"] for warning in warnings} == {"column", "measure", "calculated_table"} + assert {warning["code"] for warning in warnings} == {"dax_translation_fallback"} + finally: + temp_path.unlink() + + +def test_tmdl_import_collects_dax_parse_warnings(monkeypatch): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + calculatedColumn BadColumn = BADFUNC(Sales[Amount]) + measure BadMeasure = BADFUNC(Sales[Amount]) + calculatedTable BadTable = BADTABLE(Sales) + """ + ) + + monkeypatch.setattr( + tmdl_module, + "_parse_dax_expression", + lambda expression, node, context: (_ for _ in ()).throw(ValueError("simulated parse error")), + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + warnings = getattr(graph, "import_warnings") + assert len(warnings) == 3 + assert {warning["context"] for warning in warnings} == {"column", "measure", "calculated_table"} + assert {warning["code"] for warning in warnings} == {"dax_parse_error"} + assert graph.models["Sales"].get_dimension("BadColumn") is not None + assert graph.models["Sales"].get_metric("BadMeasure") is not None + assert graph.models["BadTable"].dax == "BADTABLE(Sales)" + finally: + temp_path.unlink() + + +def test_tmdl_import_collects_relationship_skip_warnings(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship BadReference + fromColumn: SalesProductKey + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + relationship MissingModel + fromColumn: Sales[ProductKey] + toColumn: Missing[ProductKey] + fromCardinality: many + toCardinality: one + relationship BadCardinality + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: several + toCardinality: one + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + warnings = getattr(graph, "import_warnings") + relationship_warnings = [ + warning + for warning in warnings + if warning.get("code") == "relationship_parse_skip" and warning.get("context") == "relationship" + ] + assert len(relationship_warnings) == 3 + messages = [warning["message"] for warning in relationship_warnings] + assert any("invalid fromColumn/toColumn reference" in message for message in messages) + assert any("unknown model reference" in message for message in messages) + assert any("unsupported cardinality" in message for message in messages) + finally: + temp_path.unlink() + + +def test_tmdl_calculated_table_lowering_ignores_inactive_relationship_edges(tmp_path): + pytest.importorskip("sidemantic_dax") + _write_tmdl_dax_relationship_fixture( + tmp_path, + """ + relationship SalesProducts + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + isActive: false + """, + ) + + graph = TMDLAdapter().parse(tmp_path) + + warnings = getattr(graph, "import_warnings") + assert [warning["code"] for warning in warnings] == ["dax_unrelated_cross_join"] + assert warnings[0]["context"] == "calculated_table" + assert [(rel.name, rel.active) for rel in graph.models["Sales"].relationships] == [("Products", False)] + assert "CROSS JOIN" in graph.models["Sales By Category"].sql + assert "LEFT JOIN" not in graph.models["Sales By Category"].sql + + +def test_tmdl_calculated_table_lowering_ignores_invalid_relationship_edges(tmp_path): + pytest.importorskip("sidemantic_dax") + _write_tmdl_dax_relationship_fixture( + tmp_path, + """ + relationship SalesProducts + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: several + toCardinality: one + """, + ) + + graph = TMDLAdapter().parse(tmp_path) + warnings = getattr(graph, "import_warnings") + + assert [warning["code"] for warning in warnings] == ["dax_unrelated_cross_join", "relationship_parse_skip"] + assert warnings[0]["context"] == "calculated_table" + assert warnings[1]["context"] == "relationship" + assert "unsupported cardinality" in warnings[1]["message"] + assert graph.models["Sales"].relationships == [] + assert "CROSS JOIN" in graph.models["Sales By Category"].sql + assert "LEFT JOIN" not in graph.models["Sales By Category"].sql + + +def _write_tmdl_dax_relationship_fixture(root: Path, relationship_text: str) -> None: + definition_dir = root / "definition" + tables_dir = definition_dir / "tables" + tables_dir.mkdir(parents=True) + (definition_dir / "model.tmdl").write_text( + textwrap.dedent( + """ + model Test + ref table Sales + ref table Products + ref table 'Sales By Category' + ref relationship SalesProducts + """ + ).strip() + + "\n" + ) + (tables_dir / "Sales.tmdl").write_text( + textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column Amount + dataType: decimal + sourceColumn: Amount + """ + ).strip() + + "\n" + ) + (tables_dir / "Products.tmdl").write_text( + textwrap.dedent( + """ + table Products + column ProductKey + dataType: int64 + isKey + sourceColumn: ProductKey + column Category + dataType: string + sourceColumn: Category + """ + ).strip() + + "\n" + ) + (tables_dir / "Sales By Category.tmdl").write_text( + textwrap.dedent( + """ + calculatedTable 'Sales By Category' = SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount])) + column Category + dataType: string + sourceColumn: Category + column Revenue + dataType: decimal + sourceColumn: Revenue + """ + ).strip() + + "\n" + ) + (definition_dir / "relationships.tmdl").write_text(textwrap.dedent(relationship_text).strip() + "\n") + + +def test_tmdl_import_valid_relationship_cardinalities_do_not_emit_skip_warnings(): + tmdl = textwrap.dedent( + """ + table Sales + column SalesKey + dataType: int64 + sourceColumn: SalesKey + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column CustomerKey + dataType: int64 + sourceColumn: CustomerKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column SalesKey + dataType: int64 + sourceColumn: SalesKey + table Customers + column CustomerKey + dataType: int64 + sourceColumn: CustomerKey + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesProductsManyToOne + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + relationship ProductsCustomersOneToMany + fromColumn: Products[ProductKey] + toColumn: Customers[ProductKey] + fromCardinality: one + toCardinality: many + relationship SalesCustomersOneToOne + fromColumn: Sales[CustomerKey] + toColumn: Customers[CustomerKey] + fromCardinality: one + toCardinality: one + relationship SalesProductsManyToMany + fromColumn: Sales[SalesKey] + toColumn: Products[SalesKey] + fromCardinality: many + toCardinality: many + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + warnings = getattr(graph, "import_warnings") + relationship_warnings = [ + warning + for warning in warnings + if warning.get("code") == "relationship_parse_skip" and warning.get("context") == "relationship" + ] + assert relationship_warnings == [] + sales_relationships = { + getattr(rel, "_tmdl_relationship_name"): rel for rel in graph.models["Sales"].relationships + } + products_relationships = { + getattr(rel, "_tmdl_relationship_name"): rel for rel in graph.models["Products"].relationships + } + + many_to_one = sales_relationships["SalesProductsManyToOne"] + assert many_to_one.type == "many_to_one" + assert many_to_one.foreign_key == "ProductKey" + assert many_to_one.primary_key == "ProductKey" + assert getattr(many_to_one, "_tmdl_from_column") == "ProductKey" + assert getattr(many_to_one, "_tmdl_to_column") == "ProductKey" + + one_to_many = products_relationships["ProductsCustomersOneToMany"] + assert one_to_many.type == "one_to_many" + assert one_to_many.foreign_key == "ProductKey" + assert one_to_many.primary_key is None + assert getattr(one_to_many, "_tmdl_from_column") == "ProductKey" + assert getattr(one_to_many, "_tmdl_to_column") == "ProductKey" + + one_to_one = sales_relationships["SalesCustomersOneToOne"] + assert one_to_one.type == "one_to_one" + assert one_to_one.foreign_key == "CustomerKey" + assert one_to_one.primary_key is None + assert getattr(one_to_one, "_tmdl_from_column") == "CustomerKey" + assert getattr(one_to_one, "_tmdl_to_column") == "CustomerKey" + + many_to_many = sales_relationships["SalesProductsManyToMany"] + assert many_to_many.type == "many_to_many" + assert many_to_many.foreign_key == "SalesKey" + assert many_to_many.primary_key == "SalesKey" + + with tempfile.TemporaryDirectory() as tmpdir: + export_dir = Path(tmpdir) + TMDLAdapter().export(graph, export_dir) + relationships = (export_dir / "definition/relationships.tmdl").read_text() + assert "relationship ProductsCustomersOneToMany" in relationships + assert "fromColumn: Products[ProductKey]" in relationships + assert "toColumn: Customers[ProductKey]" in relationships + assert "relationship SalesCustomersOneToOne" in relationships + assert "fromColumn: Sales[CustomerKey]" in relationships + assert "toColumn: Customers[CustomerKey]" in relationships + finally: + temp_path.unlink() + + +def test_tmdl_warning_fixture_collects_real_unsupported_dax_warnings(): + graph = TMDLAdapter().parse("tests/fixtures/tmdl_warning") + + warnings = getattr(graph, "import_warnings") + assert [(warning["code"], warning["context"], warning["name"]) for warning in warnings] == [ + ("dax_translation_fallback", "calculated_table", "Bad Table"), + ("dax_translation_fallback", "column", "Bad Column"), + ("dax_translation_fallback", "measure", "Bad Measure"), + ("relationship_parse_skip", "relationship", "Bad-Relationship"), + ] + assert all(warning.get("file") for warning in warnings) + assert all(isinstance(warning.get("line"), int) and warning["line"] >= 1 for warning in warnings) + assert all(isinstance(warning.get("column"), int) and warning["column"] >= 1 for warning in warnings) + + +# ============================================================================= +# LOADER TESTS +# ============================================================================= + + +def test_tmdl_loader_auto_detection(): + """Test load_from_directory auto-detects TMDL projects.""" + layer = SemanticLayer() + load_from_directory(layer, "tests/fixtures/tmdl") + assert "Sales" in layer.graph.models + assert "Products" in layer.graph.models + + +def test_tmdl_loader_auto_detects_standalone_tmdl_files(tmp_path): + """Directory loading should treat root .tmdl files as one TMDL source.""" + (tmp_path / "Sales.tmdl").write_text( + textwrap.dedent( + """ + model DemoModel + ref table Sales + table Sales + column SaleID + dataType: int64 + isKey + sourceColumn: SaleID + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + """ + ) + ) + + layer = SemanticLayer() + load_from_directory(layer, tmp_path) + + assert set(layer.graph.models) == {"Sales"} + description = layer.describe_models() + assert description["import_warnings"] == [] + + sales = description["models"][0] + revenue = next(metric for metric in sales["metrics"] if metric["name"] == "Revenue") + assert sales["source_format"] == "TMDL" + assert sales["source_file"] == "Sales.tmdl" + assert revenue["source_format"] == "TMDL" + assert revenue["source_file"] == "Sales.tmdl" + assert revenue["dax"] == "SUM(Sales[Amount])" + assert revenue["dax_lowered"] is True + assert revenue["faithful_lowering"] is True + + +def test_tmdl_loader_preserves_graph_passthrough_for_export(tmp_path): + """CLI-style directory loading should keep graph-level TMDL metadata.""" + definition_dir = tmp_path / "definition" + tables_dir = definition_dir / "tables" + tables_dir.mkdir(parents=True) + (definition_dir / "database.tmdl").write_text( + textwrap.dedent( + """ + database DemoDB + compatibilityLevel: 1601 + model DemoModel + """ + ) + ) + (definition_dir / "model.tmdl").write_text( + textwrap.dedent( + """ + model DemoModel + perspective SalesView + annotation Scope + value: "all" + ref table Sales + role Analysts + modelPermission: read + """ + ) + ) + (tables_dir / "Sales.tmdl").write_text( + textwrap.dedent( + """ + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + """ + ) + ) + + layer = SemanticLayer() + load_from_directory(layer, tmp_path) + + export_dir = tmp_path / "exported" + TMDLAdapter().export(layer.graph, export_dir) + + database_content = (export_dir / "definition" / "database.tmdl").read_text() + model_content = (export_dir / "definition" / "model.tmdl").read_text() + assert "database DemoDB" in database_content + assert "compatibilityLevel: 1601" in database_content + assert "model DemoModel" in database_content + assert "model DemoModel" in model_content + assert "perspective SalesView" in model_content + assert 'value: "all"' in model_content + assert "role Analysts" in model_content + + +def test_tmdl_loader_auto_detects_standalone_tmdl_file_in_directory(tmp_path): + """Test load_from_directory auto-detects standalone TMDL files outside definition/.""" + (tmp_path / "Sales.tmdl").write_text( + textwrap.dedent( + """ + table Sales + column SaleID + dataType: int64 + isKey + sourceColumn: SaleID + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + """ + ) + ) + + layer = SemanticLayer() + load_from_directory(layer, tmp_path) + + assert "Sales" in layer.graph.models + assert layer.graph.models["Sales"]._source_format == "TMDL" + assert layer.graph.models["Sales"].get_metric("Revenue").sql == "Amount" + + +def test_semantic_layer_from_yaml_loads_standalone_tmdl_file(tmp_path): + """Sidequery's single-file bridge calls from_yaml, so .tmdl must dispatch there too.""" + tmdl_file = tmp_path / "Sales.tmdl" + tmdl_file.write_text( + textwrap.dedent( + """ + table Sales + column SaleID + dataType: int64 + isKey + sourceColumn: SaleID + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + """ + ) + ) + + layer = SemanticLayer.from_yaml(tmdl_file) + + assert "Sales" in layer.graph.models + sales = layer.graph.models["Sales"] + assert sales._source_format == "TMDL" + assert sales.get_metric("Revenue").dax == "SUM(Sales[Amount])" + assert sales.get_metric("Revenue").sql == "Amount" + + +def test_tmdl_loader_propagates_import_warnings(monkeypatch, tmp_path): + definition_dir = tmp_path / "definition" + definition_dir.mkdir(parents=True) + (definition_dir / "model.tmdl").write_text("model Demo") + + def _fake_parse(self, source): + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="orders", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="count", agg="count")], + ) + ) + graph.import_warnings = [ + { + "code": "dax_parse_error", + "context": "measure", + "name": "Revenue", + "message": "Simulated parse error", + } + ] + return graph + + monkeypatch.setattr(TMDLAdapter, "parse", _fake_parse) + + layer = SemanticLayer() + load_from_directory(layer, tmp_path) + warnings = getattr(layer.graph, "import_warnings") + assert len(warnings) == 1 + assert warnings[0]["code"] == "dax_parse_error" + + +def test_tmdl_import_warns_when_dax_parser_unavailable(monkeypatch): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + """ + ) + + monkeypatch.setattr( + tmdl_module, + "_parse_dax_expression", + lambda expression, node, context: (_ for _ in ()).throw( + tmdl_module.DaxRuntimeUnavailableError("simulated missing parser") + ), + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + warnings = getattr(graph, "import_warnings") + assert warnings[0]["code"] == "dax_parser_unavailable" + assert warnings[0]["model"] == "Sales" + assert graph.models["Sales"].get_metric("Revenue").dax == "SUM(Sales[Amount])" + + +def test_tmdl_import_warnings_are_model_qualified_for_duplicate_names(monkeypatch): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = UNSUPPORTED(Sales[Amount]) + table Returns + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = UNSUPPORTED(Returns[Amount]) + """ + ) + + monkeypatch.setattr(tmdl_module, "_parse_dax_expression", lambda expression, node, context: object()) + monkeypatch.setattr( + tmdl_module, + "translate_dax_metric", + lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("metric unsupported")), + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + warnings = getattr(graph, "import_warnings") + assert {(warning["model"], warning["name"]) for warning in warnings} == { + ("Sales", "Revenue"), + ("Returns", "Revenue"), + } + + description = describe_graph(graph) + sales = next(model for model in description["models"] if model["name"] == "Sales") + returns = next(model for model in description["models"] if model["name"] == "Returns") + sales_revenue = next(metric for metric in sales["metrics"] if metric["name"] == "Revenue") + returns_revenue = next(metric for metric in returns["metrics"] if metric["name"] == "Revenue") + + assert sales_revenue["import_warnings"][0]["model"] == "Sales" + assert returns_revenue["import_warnings"][0]["model"] == "Returns" + + +def test_tmdl_describe_graph_exposes_source_metadata_for_models_fields_and_relationships(): + graph = TMDLAdapter().parse("tests/fixtures/tmdl") + description = describe_graph(graph) + json.dumps(description) + sales = next(model for model in description["models"] if model["name"] == "Sales") + order_date = next(dimension for dimension in sales["dimensions"] if dimension["name"] == "Order Date") + total_sales = next(metric for metric in sales["metrics"] if metric["name"] == "Total Sales") + products_rel = next(relationship for relationship in sales["relationships"] if relationship["name"] == "Products") + + assert sales["source_format"] == "TMDL" + assert sales["source_file"] == "tables/Sales.tmdl" + assert order_date["source_format"] == "TMDL" + assert order_date["source_file"] == "tables/Sales.tmdl" + assert total_sales["source_format"] == "TMDL" + assert total_sales["source_file"] == "tables/Sales.tmdl" + assert products_rel["source_format"] == "TMDL" + assert products_rel["source_file"] == "relationships.tmdl" + assert products_rel["tmdl_name"] == "Sales-Products" + assert sales["tmdl"]["name_raw"] == "'Sales'" + assert sales["tmdl"]["leading_comments"] == ["# comment that should be ignored"] + assert order_date["tmdl"]["data_type"] == "date" + assert order_date["tmdl"]["raw_value_properties"]["sourcecolumn"] == "OrderDate" + assert total_sales["tmdl"]["raw_value_properties"]["formatstring"] == '"$#,##0.00"' + assert products_rel["tmdl"]["relationship_name"] == "Sales-Products" + assert products_rel["tmdl"]["relationship_name_raw"] == "'Sales-Products'" + assert products_rel["tmdl"]["raw_value_properties"]["fromcolumn"] == "'Sales'[Product Key]" + assert products_rel["tmdl"]["is_active_explicit"] is True + + +# ============================================================================= +# EXPORT TESTS +# ============================================================================= + + +def test_tmdl_export_simple_model(): + """Test exporting a simple model to TMDL.""" + model = Model( + name="orders", + table="orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + Dimension(name="order_date", type="time", sql="order_date", granularity="day"), + ], + metrics=[ + Metric(name="count", agg="count"), + Metric(name="revenue", agg="sum", sql="amount"), + Metric(name="median_revenue", agg="median", sql="amount"), + ], + ) + + graph = SemanticGraph() + graph.add_model(model) + + adapter = TMDLAdapter() + + with tempfile.TemporaryDirectory() as tmpdir: + adapter.export(graph, tmpdir) + + table_file = Path(tmpdir) / "definition" / "tables" / "orders.tmdl" + assert table_file.exists() + + content = table_file.read_text() + assert "table orders" in content + assert "column id" in content + assert "isKey" in content + assert "measure revenue = SUM(orders[amount])" in content + assert "measure median_revenue = MEDIAN(orders[amount])" in content + + model_file = Path(tmpdir) / "definition" / "model.tmdl" + assert model_file.exists() + + +def test_tmdl_export_relationships(): + """Test exporting relationships to relationships.tmdl.""" + orders = Model( + name="orders", + table="orders", + primary_key="id", + dimensions=[Dimension(name="customer_id", type="numeric", sql="customer_id")], + relationships=[Relationship(name="customers", type="many_to_one", foreign_key="customer_id")], + ) + customers = Model(name="customers", table="customers", primary_key="id") + + graph = SemanticGraph() + graph.add_model(orders) + graph.add_model(customers) + + adapter = TMDLAdapter() + + with tempfile.TemporaryDirectory() as tmpdir: + adapter.export(graph, tmpdir) + + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + assert rel_file.exists() + + content = rel_file.read_text() + assert "fromColumn: orders[customer_id]" in content + assert "toColumn: customers[id]" in content + assert "fromCardinality: many" in content + assert "toCardinality: one" in content + + +def test_tmdl_export_many_to_many_relationships(): + orders = Model( + name="orders", + table="orders", + primary_key="id", + relationships=[ + Relationship( + name="products", + type="many_to_many", + foreign_key="order_product_key", + primary_key="product_order_key", + active=False, + ) + ], + ) + products = Model(name="products", table="products", primary_key="id") + + graph = SemanticGraph() + graph.add_model(orders) + graph.add_model(products) + + adapter = TMDLAdapter() + + with tempfile.TemporaryDirectory() as tmpdir: + adapter.export(graph, tmpdir) + + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + model_file = Path(tmpdir) / "definition" / "model.tmdl" + rel_content = rel_file.read_text() + model_content = model_file.read_text() + + assert "fromColumn: orders[order_product_key]" in rel_content + assert "toColumn: products[product_order_key]" in rel_content + assert "fromCardinality: many" in rel_content + assert "toCardinality: many" in rel_content + assert "isActive: false" in rel_content + assert "ref relationship orders_products" in model_content + + +def test_tmdl_export_collects_relationship_skip_warnings(): + orders = Model( + name="orders", + table="orders", + primary_key="id", + relationships=[Relationship(name="customers", type="many_to_one", foreign_key="customer_id")], + ) + + graph = SemanticGraph() + graph.add_model(orders) + + adapter = TMDLAdapter() + with tempfile.TemporaryDirectory() as tmpdir: + adapter.export(graph, tmpdir) + warnings = getattr(graph, "export_warnings") + assert len(warnings) == 1 + warning = warnings[0] + assert warning["code"] == "relationship_export_skip" + assert warning["context"] == "relationship" + assert warning["from_model"] == "orders" + assert warning["to_model"] == "customers" + assert "related model not found" in warning["message"] + assert not (Path(tmpdir) / "definition" / "relationships.tmdl").exists() + + +def test_tmdl_export_supported_relationship_types_do_not_emit_skip_warnings(): + sales = Model( + name="sales", + table="sales", + primary_key="sales_key", + dimensions=[ + Dimension(name="product_key", type="numeric", sql="product_key"), + Dimension(name="customer_key", type="numeric", sql="customer_key"), + ], + relationships=[ + Relationship(name="products", type="many_to_one", foreign_key="product_key", primary_key="product_key"), + Relationship(name="customers", type="one_to_one", foreign_key="customer_key", primary_key="customer_key"), + ], + ) + products = Model( + name="products", + table="products", + primary_key="product_key", + dimensions=[ + Dimension(name="sales_key", type="numeric", sql="sales_key"), + Dimension(name="customer_key", type="numeric", sql="customer_key"), + ], + relationships=[ + Relationship(name="customers", type="one_to_many", foreign_key="customer_key"), + Relationship(name="sales", type="many_to_many", foreign_key="sales_key", primary_key="sales_key"), + ], + ) + customers = Model(name="customers", table="customers", primary_key="customer_key") + + graph = SemanticGraph() + graph.add_model(sales) + graph.add_model(products) + graph.add_model(customers) + + adapter = TMDLAdapter() + with tempfile.TemporaryDirectory() as tmpdir: + adapter.export(graph, tmpdir) + warnings = getattr(graph, "export_warnings") + assert warnings == [] + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + content = rel_file.read_text() + assert "fromCardinality: many" in content + assert "toCardinality: one" in content + assert "fromCardinality: one" in content + assert "toCardinality: many" in content + + +def test_tmdl_export_preserves_calculated_table_declaration(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column Amount + dataType: decimal + sourceColumn: Amount + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column Category + dataType: string + sourceColumn: Category + calculatedTable SalesByCategory = SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount])) + relationship SalesProducts + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "SalesByCategory.tmdl" + content = table_file.read_text() + assert ( + 'calculatedTable SalesByCategory = SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' + in content + ) + + +def test_tmdl_export_preserves_imported_relationship_names(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + model_file = Path(tmpdir) / "definition" / "model.tmdl" + rel_content = rel_file.read_text() + model_content = model_file.read_text() + assert "relationship SalesToProductsByKey" in rel_content + assert "ref relationship SalesToProductsByKey" in model_content + + +def test_tmdl_export_preserves_imported_relationship_properties(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + crossFilteringBehavior: bothDirections + relyOnReferentialIntegrity: true + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert "crossFilteringBehavior: bothDirections" in rel_content + assert "relyOnReferentialIntegrity: true" in rel_content + + +def test_tmdl_export_preserves_relationship_child_nodes(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + annotation RelationshipTag + value: "relationship_meta" + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + rel = graph.models["Sales"].relationships[0] + relationship_info = describe_graph(graph)["models"][0]["relationships"][0] + assert relationship_info["tmdl"]["child_nodes"][0]["name"] == "RelationshipTag" + assert getattr(rel, "_tmdl_child_nodes")[0].name == "RelationshipTag" + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert "annotation RelationshipTag" in rel_content + assert 'value: "relationship_meta"' in rel_content + + +def test_tmdl_export_preserves_core_relationship_raw_literals(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + fromColumn: "Sales[ProductKey]" + toColumn: "Products[ProductKey]" + fromCardinality: "many" + toCardinality: "one" + isActive: FALSE + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert 'fromColumn: "Sales[ProductKey]"' in rel_content + assert 'toColumn: "Products[ProductKey]"' in rel_content + assert 'fromCardinality: "many"' in rel_content + assert 'toCardinality: "one"' in rel_content + assert "isActive: FALSE" in rel_content + + +def test_tmdl_export_preserves_imported_relationship_core_property_order(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + toColumn: Products[ProductKey] + fromColumn: Sales[ProductKey] + toCardinality: one + fromCardinality: many + isActive: FALSE + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert rel_content.index("toColumn: Products[ProductKey]") < rel_content.index("fromColumn: Sales[ProductKey]") + assert rel_content.index("fromColumn: Sales[ProductKey]") < rel_content.index("toCardinality: one") + assert rel_content.index("toCardinality: one") < rel_content.index("fromCardinality: many") + assert rel_content.index("fromCardinality: many") < rel_content.index("isActive: FALSE") + + +def test_tmdl_export_preserves_relationship_isactive_true_raw_literal(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + isActive: TRUE + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert "isActive: TRUE" in rel_content + + +def test_tmdl_export_preserves_relationship_isactive_bare_property(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + isActive + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert "\n isActive\n" in rel_content + assert "isActive: true" not in rel_content + + +def test_tmdl_export_preserves_relationship_description_raw_literal(): + tmdl = textwrap.dedent( + ''' + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + relationship SalesToProductsByKey + description: "Rel ""Desc""" + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + ''' + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert 'description: "Rel ""Desc"""' in rel_content + assert "/// Rel" not in rel_content + + +def test_tmdl_export_preserves_imported_relationship_description(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + /// Sales to products relationship + relationship SalesToProductsByKey + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert "/// Sales to products relationship" in rel_content + assert "relationship SalesToProductsByKey" in rel_content + + +def test_tmdl_export_preserves_relationship_leading_comments(): + tmdl = textwrap.dedent( + """ + table Sales + column ProductKey + dataType: int64 + sourceColumn: ProductKey + table Products + column ProductKey + dataType: int64 + sourceColumn: ProductKey + // Relationship comment + relationship SalesToProductsByKey + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + rel_content = rel_file.read_text() + assert "// Relationship comment\nrelationship SalesToProductsByKey" in rel_content + + +def test_tmdl_export_preserves_imported_measure_and_calculated_column_expressions(): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + column Quantity + dataType: int64 + sourceColumn: Quantity + calculatedColumn Net = Sales[Amount] - 1 + dataType: decimal + measure 'Avg Price' = DIVIDE(SUM(Sales[Amount]), SUM(Sales[Quantity]), 0) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert "calculatedColumn Net" in content + assert "expression = Sales[Amount] - 1" in content + assert "measure 'Avg Price' = DIVIDE(SUM(Sales[Amount]), SUM(Sales[Quantity]), 0)" in content + + +def test_tmdl_export_preserves_native_dax_authored_sources(tmp_path): + pytest.importorskip("sidemantic_dax") + source = tmp_path / "models.yml" + source.write_text( + """ +models: + - name: Sales + table: Sales + primary_key: ID + dimensions: + - name: ID + type: numeric + - name: Amount + type: numeric + - name: Quantity + type: numeric + - name: Net + type: numeric + dax: "Sales[Amount] - 1" + metrics: + - name: Avg Price + dax: "DIVIDE(SUM(Sales[Amount]), SUM(Sales[Quantity]), 0)" + - name: Positive Sales + primary_key: ID + dax: "FILTER(Sales, Sales[Amount] > 0)" + dimensions: + - name: ID + type: numeric +""" + ) + layer = SemanticLayer.from_yaml(source) + export_dir = tmp_path / "exported_tmdl" + + TMDLAdapter().export(layer.graph, export_dir) + + sales_tmdl = (export_dir / "definition" / "tables" / "Sales.tmdl").read_text() + positive_tmdl = next((export_dir / "definition" / "tables").glob("Positive*.tmdl")).read_text() + assert "calculatedColumn Net" in sales_tmdl + assert "expression = Sales[Amount] - 1" in sales_tmdl + assert "measure 'Avg Price' = DIVIDE(SUM(Sales[Amount]), SUM(Sales[Quantity]), 0)" in sales_tmdl + assert "calculatedTable 'Positive Sales' = FILTER(Sales, Sales[Amount] > 0)" in positive_tmdl + + reparsed = TMDLAdapter().parse(export_dir) + sales = reparsed.models["Sales"] + positive_sales = reparsed.models["Positive Sales"] + assert sales.get_dimension("Net").dax == "Sales[Amount] - 1" + assert sales.get_metric("Avg Price").dax == "DIVIDE(SUM(Sales[Amount]), SUM(Sales[Quantity]), 0)" + assert positive_sales.dax == "FILTER(Sales, Sales[Amount] > 0)" + assert positive_sales.table is None + assert getattr(reparsed, "import_warnings") == [] + + +def test_tmdl_export_preserves_expression_meta_for_measure_and_calculated_column(): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + calculatedColumn Net + dataType: decimal + expression = Sales[Amount] - 1 meta [lineageTag="NetLineage", isHidden=true] + measure Revenue = SUM(Sales[Amount]) meta [displayFolder="KPIs", isSimpleMeasure=true] + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + warnings = getattr(graph, "import_warnings") + assert not any(warning.get("code") == "dax_parse_error" and warning.get("name") == "Net" for warning in warnings) + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert 'expression = Sales[Amount] - 1 meta [lineageTag="NetLineage", isHidden=true]' in content + assert 'measure Revenue = SUM(Sales[Amount]) meta [displayFolder="KPIs", isSimpleMeasure=true]' in content + + +def test_tmdl_export_preserves_imported_column_and_measure_passthrough_properties(): + tmdl = textwrap.dedent( + """ + table Sales + lineageTag: SalesLineage + column DateKey + dataType: date + sourceColumn: DateKey + sortByColumn: Sales[SortKey] + summarizeBy: none + isHidden: true + displayFolder: Time + column SortKey + dataType: int64 + sourceColumn: SortKey + measure 'Total Sales' = SUM(Sales[Amount]) + displayFolder: KPIs + detailRowsExpression = Sales + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert "lineageTag: SalesLineage" in content + assert "sortByColumn: Sales[SortKey]" in content + assert "summarizeBy: none" in content + assert "isHidden: true" in content + assert "displayFolder: Time" in content + assert "displayFolder: KPIs" in content + assert "detailRowsExpression = Sales" in content + assert "column SortKey" in content + assert "dataType: int64" in content + + +def test_tmdl_is_hidden_maps_to_public_false_and_exports(): + tmdl = textwrap.dedent( + """ + table Sales + column InternalCategory + dataType: string + sourceColumn: Category + isHidden: true + column VisibleCategory + dataType: string + sourceColumn: Category + column Amount + dataType: decimal + sourceColumn: Amount + measure 'Internal Revenue' = SUM(Sales[Amount]) + isHidden: true + measure 'Visible Revenue' = SUM(Sales[Amount]) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + sales = graph.models["Sales"] + assert sales.get_dimension("InternalCategory").public is False + assert sales.get_dimension("VisibleCategory").public is True + assert sales.get_metric("Internal Revenue").public is False + assert sales.get_metric("Visible Revenue").public is True + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert "column InternalCategory" in content + assert "isHidden: true" in content + assert "measure 'Internal Revenue' = SUM(Sales[Amount])" in content + + +def test_tmdl_export_preserves_passthrough_expression_meta_with_block(): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + detailRowsExpression = meta [lineageTag="DetailRowsExpr"] + FILTER(Sales, Sales[Amount] > 0) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert 'detailRowsExpression = meta [lineageTag="DetailRowsExpr"]' in content + assert "FILTER(Sales, Sales[Amount] > 0)" in content + + +def test_tmdl_export_preserves_passthrough_child_nodes(): + tmdl = textwrap.dedent( + """ + table Sales + annotation TableTag + value: "table_meta" + column Amount + dataType: decimal + sourceColumn: Amount + annotation ColumnTag + value: "column_meta" + measure Revenue = SUM(Sales[Amount]) + annotation MeasureTag + value: "measure_meta" + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert "annotation TableTag" in content + assert 'value: "table_meta"' in content + assert "annotation ColumnTag" in content + assert 'value: "column_meta"' in content + assert "annotation MeasureTag" in content + assert 'value: "measure_meta"' in content + + +def test_tmdl_multiline_dax_expression_preserves_embedded_comments(): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = + // preserve this DAX comment + SUM(Sales[Amount]) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + revenue = graph.models["Sales"].get_metric("Revenue") + assert "// preserve this DAX comment" in revenue.dax + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert "// preserve this DAX comment" in content + assert "SUM(Sales[Amount])" in content + + +def test_tmdl_export_preserves_model_level_passthrough_nodes_and_properties(): + tmdl = textwrap.dedent( + """ + model Demo + defaultPowerBIDataSourceVersion: powerBI_V3 + perspective SalesView + annotation Scope + value: "all" + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + model_file = Path(tmpdir) / "definition" / "model.tmdl" + content = model_file.read_text() + assert "defaultPowerBIDataSourceVersion: powerBI_V3" in content + assert "perspective SalesView" in content + assert "annotation Scope" in content + assert 'value: "all"' in content + + +def test_tmdl_export_preserves_root_level_passthrough_nodes(): + tmdl = textwrap.dedent( + """ + model Demo + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + role Analysts + modelPermission: read + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + model_file = Path(tmpdir) / "definition" / "model.tmdl" + content = model_file.read_text() + assert "role Analysts" in content + assert "modelPermission: read" in content + + +def test_tmdl_export_preserves_database_passthrough_and_names(): + tmdl = textwrap.dedent( + """ + /// Demo database + database DemoDB + compatibilityLevel: 1601 + annotation DbTag + value: "db_meta" + model DemoModel + model DemoModel + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + database_file = Path(tmpdir) / "definition" / "database.tmdl" + model_file = Path(tmpdir) / "definition" / "model.tmdl" + database_content = database_file.read_text() + model_content = model_file.read_text() + assert "/// Demo database" in database_content + assert "database DemoDB" in database_content + assert "compatibilityLevel: 1601" in database_content + assert "annotation DbTag" in database_content + assert 'value: "db_meta"' in database_content + assert "model DemoModel" in database_content + assert "model DemoModel" in model_content + + +def test_tmdl_export_preserves_database_and_model_leading_comments(): + tmdl = textwrap.dedent( + """ + # Database heading comment + database DemoDB + model DemoModel + // Model heading comment + model DemoModel + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + database_file = Path(tmpdir) / "definition" / "database.tmdl" + model_file = Path(tmpdir) / "definition" / "model.tmdl" + database_content = database_file.read_text() + model_content = model_file.read_text() + assert database_content.startswith("# Database heading comment\n") + assert model_content.startswith("// Model heading comment\n") + + +def test_tmdl_export_preserves_database_and_model_description_raw_literals(): + tmdl = textwrap.dedent( + ''' + database DemoDB + description: "DB ""Desc""" + model DemoModel + model DemoModel + description: "Model ""Desc""" + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + ''' + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + database_file = Path(tmpdir) / "definition" / "database.tmdl" + model_file = Path(tmpdir) / "definition" / "model.tmdl" + database_content = database_file.read_text() + model_content = model_file.read_text() + assert 'description: "DB ""Desc"""' in database_content + assert 'description: "Model ""Desc"""' in model_content + assert "/// DB" not in database_content + assert "/// Model" not in model_content + + +def test_tmdl_export_script_file(): + """Test exporting to a single TMDL script file.""" + model = Model(name="orders", table="orders", primary_key="id") + graph = SemanticGraph() + graph.add_model(model) + + adapter = TMDLAdapter() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + temp_path = Path(f.name) + + try: + adapter.export(graph, temp_path) + content = temp_path.read_text() + assert "createOrReplace" in content + assert "table orders" in content + reparsed = adapter.parse(temp_path) + assert set(reparsed.models) == {"orders"} + assert reparsed.models["orders"].primary_key == "id" + finally: + temp_path.unlink() + + +def test_tmdl_export_script_preserves_database_model_and_root_passthrough(): + tmdl = textwrap.dedent( + """ + database DemoDB + compatibilityLevel: 1601 + model DemoModel + model DemoModel + perspective SalesView + annotation Scope + value: "all" + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + role Analysts + modelPermission: read + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + src_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(src_path) + finally: + src_path.unlink() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + out_path = Path(f.name) + + try: + TMDLAdapter().export(graph, out_path) + content = out_path.read_text() + assert "createOrReplace" in content + assert "database DemoDB" in content + assert "compatibilityLevel: 1601" in content + assert "model DemoModel" in content + assert "perspective SalesView" in content + assert "role Analysts" in content + assert "modelPermission: read" in content + assert "table Sales" in content + reparsed = TMDLAdapter().parse(out_path) + assert set(reparsed.models) == {"Sales"} + assert getattr(reparsed, "_tmdl_database_name") == "DemoDB" + assert getattr(reparsed, "_tmdl_model_name") == "DemoModel" + assert getattr(reparsed, "_tmdl_database_properties")[0]["name"] == "compatibilityLevel" + assert getattr(reparsed, "_tmdl_model_child_nodes")[0].name == "SalesView" + assert getattr(reparsed, "_tmdl_root_nodes")[0].type == "role" + finally: + out_path.unlink() + + +def test_tmdl_export_script_preserves_realistic_project_metadata(tmp_path): + fixture_root = Path("tests/fixtures/tmdl_realistic") + graph = TMDLAdapter().parse(fixture_root) + out_path = tmp_path / "retail_analytics.tmdl" + + TMDLAdapter().export(graph, out_path) + content = out_path.read_text() + + assert "createOrReplace" in content + assert "database 'Retail Analytics'" in content + assert "compatibilityLevel: 1601" in content + assert "annotation DatabaseTag" in content + assert "perspective Executive" in content + assert "culture en-US" in content + assert "role 'Sales Managers'" in content + assert "partition Sales = m" in content + assert "calculatedTable 'Sales By Category'" in content + assert "annotation CalculationTag" in content + assert "relationship 'Sales-Products'" in content + assert "annotation RelationshipLineage" in content + + reparsed = TMDLAdapter().parse(out_path) + assert getattr(reparsed, "import_warnings") == [] + assert set(reparsed.models) == {"Sales", "Products", "Calendar", "Sales By Category"} + assert getattr(reparsed, "_tmdl_database_name") == "Retail Analytics" + assert getattr(reparsed, "_tmdl_model_child_nodes")[0].name == "Executive" + + reparsed_sales = reparsed.models["Sales"] + reparsed_calculated = reparsed.models["Sales By Category"] + reparsed_rel = next(rel for rel in reparsed_sales.relationships if rel.name == "Products") + assert reparsed_sales.get_dimension("Amount x2").dax == "Sales[Amount] * 2" + assert reparsed_sales.get_metric("Total Sales").dax == "SUM(Sales[Amount])" + assert getattr(reparsed_calculated, "_tmdl_child_nodes")[0].name == "CalculationTag" + assert getattr(reparsed_rel, "_tmdl_child_nodes")[0].name == "RelationshipLineage" + assert getattr(reparsed_rel, "_tmdl_from_column") == "ProductKey" + assert getattr(reparsed_rel, "_tmdl_to_column") == "ProductKey" + + +def test_tmdl_export_preserves_core_property_raw_literals(): + tmdl = textwrap.dedent( + """ + table Sales + column DateKey + dataType: "date" + sourceColumn: "Date Key" + caption: "Order Date" + formatString: "yyyy-MM-dd" + measure Revenue = SUM(Sales[DateKey]) + caption: "Revenue Label" + formatString: "0.00" + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert 'dataType: "date"' in content + assert 'sourceColumn: "Date Key"' in content + assert 'caption: "Order Date"' in content + assert 'formatString: "yyyy-MM-dd"' in content + assert 'caption: "Revenue Label"' in content + assert 'formatString: "0.00"' in content + + +def test_tmdl_export_preserves_iskey_raw_literals(): + tmdl = textwrap.dedent( + """ + table Sales + column DateKey + dataType: int64 + isKey: TRUE + sourceColumn: DateKey + column ProductKey + dataType: int64 + isKey: FALSE + sourceColumn: ProductKey + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert "isKey: TRUE" in content + assert "isKey: FALSE" in content + assert "\n isKey\n" not in content + + +def test_tmdl_export_preserves_table_and_measure_description_raw_literals(): + tmdl = textwrap.dedent( + ''' + table Sales + description: "Table ""Desc""" + column ID + dataType: int64 + isKey + sourceColumn: ID + measure Revenue = SUM(Sales[ID]) + description: "Measure ""Desc""" + ''' + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert 'description: "Table ""Desc"""' in content + assert 'description: "Measure ""Desc"""' in content + assert "/// Table" not in content + assert "/// Measure" not in content + + +def test_tmdl_export_preserves_raw_identifier_literals(): + tmdl = textwrap.dedent( + """ + database "Demo DB" + model "Demo Model" + model "Demo Model" + table "Sales Table" + column "Sale ID" + dataType: int64 + isKey + sourceColumn: "Sale ID" + table "Products Table" + column "Product ID" + dataType: int64 + isKey + sourceColumn: "Product ID" + relationship "Sales To Products" + fromColumn: "Sales Table[Sale ID]" + toColumn: "Products Table[Product ID]" + fromCardinality: many + toCardinality: one + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + database_file = Path(tmpdir) / "definition" / "database.tmdl" + model_file = Path(tmpdir) / "definition" / "model.tmdl" + sales_table_file = Path(tmpdir) / "definition" / "tables" / "Sales_Table.tmdl" + products_table_file = Path(tmpdir) / "definition" / "tables" / "Products_Table.tmdl" + rel_file = Path(tmpdir) / "definition" / "relationships.tmdl" + + assert sales_table_file.exists() + assert products_table_file.exists() + + database_content = database_file.read_text() + model_content = model_file.read_text() + sales_content = sales_table_file.read_text() + rel_content = rel_file.read_text() + + assert 'database "Demo DB"' in database_content + assert 'model "Demo Model"' in database_content + assert 'model "Demo Model"' in model_content + assert 'ref table "Sales Table"' in model_content + assert 'ref table "Products Table"' in model_content + assert 'ref relationship "Sales To Products"' in model_content + assert 'table "Sales Table"' in sales_content + assert 'column "Sale ID"' in sales_content + assert 'relationship "Sales To Products"' in rel_content + + +def test_tmdl_export_preserves_escaped_quote_value_literals(): + tmdl = textwrap.dedent( + ''' + table Sales + column ID + dataType: int64 + isKey + sourceColumn: ID + caption: "Order ""ID""" + ''' + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + finally: + temp_path.unlink() + + sales = graph.models["Sales"] + dim = sales.get_dimension("ID") + assert dim is not None + assert dim.label == 'Order "ID"' + + with tempfile.TemporaryDirectory() as tmpdir: + TMDLAdapter().export(graph, tmpdir) + table_file = Path(tmpdir) / "definition" / "tables" / "Sales.tmdl" + content = table_file.read_text() + assert 'caption: "Order ""ID"""' in content + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/dax/fixtures/README.md b/tests/dax/fixtures/README.md new file mode 100644 index 00000000..d75a7a84 --- /dev/null +++ b/tests/dax/fixtures/README.md @@ -0,0 +1,31 @@ +# DAX Fixture Sources + +This directory contains DAX examples and lexer keyword lists sourced from permissively licensed open-source projects. +Each subdirectory includes the upstream LICENSE file. + +Sources +- pbi_parsers (MIT license) + - Repo: https://github.com/douglassimonsen/pbi_parsers + - Commit: 3b6aba9ff4f3a1a523ae79da7c8cb19d57e6f831 + - Files used: `docs/docs/index.md` examples (DAX expressions). + +- PyDAXLexer (MIT license) + - Repo: https://github.com/jurgenfolz/PyDAXLexer + - Commit: 3fec0fbe80777fa98b652efb62d677d2930fd997 + - Files used: `resources/sample_dax_expressions/*`, `tests/*.py`, `main.py` (DAX expressions). + +- TabularEditor (MIT license) + - Repo: https://github.com/TabularEditor/TabularEditor + - Commit: 9d3456cfdf05aac16bb73131cc4c34f3dcd62d93 + - Files used: `AntlrGrammars/DAXLexer.g4` (keyword list). + +- query-docs (CC BY 4.0 for docs, MIT for code) + - Repo: https://github.com/MicrosoftDocs/query-docs + - Commit: b1008faf12c519f7b649cd492ec83d98914c07fc + - Files used: `query-languages/dax/dax-queries.md` (DAX query examples). + +Notes +- Expressions and queries are stored as blocks separated by `---`. +- Only ASCII expressions were included to keep fixtures portable. +- Duplicates were removed. +- Expressions containing standalone `=>` function definitions were excluded for now. diff --git a/tests/dax/fixtures/pbi_parsers/LICENSE b/tests/dax/fixtures/pbi_parsers/LICENSE new file mode 100644 index 00000000..1b62fcd9 --- /dev/null +++ b/tests/dax/fixtures/pbi_parsers/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 douglassimonsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/dax/fixtures/pbi_parsers/expressions.txt b/tests/dax/fixtures/pbi_parsers/expressions.txt new file mode 100644 index 00000000..729c01fd --- /dev/null +++ b/tests/dax/fixtures/pbi_parsers/expressions.txt @@ -0,0 +1,12 @@ +# source: docs/docs/index.md +func.name(arg1 + 1 + 2 + 3, func(), func(10000000000000), arg2) +--- +# source: docs/docs/index.md +func.name( + arg1 + + 1 + + 2 + 3, + func(), + func(10000000000000), + arg2 + ) diff --git a/tests/dax/fixtures/pydaxlexer/LICENSE b/tests/dax/fixtures/pydaxlexer/LICENSE new file mode 100644 index 00000000..fc4d6efe --- /dev/null +++ b/tests/dax/fixtures/pydaxlexer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Klaus Jürgen Folz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/dax/fixtures/pydaxlexer/expressions.txt b/tests/dax/fixtures/pydaxlexer/expressions.txt new file mode 100644 index 00000000..2b0013f0 --- /dev/null +++ b/tests/dax/fixtures/pydaxlexer/expressions.txt @@ -0,0 +1,520 @@ +# source: resources/sample_dax_expressions/calc_column_dimCities_Column.txt +kanton_zug_udf() +--- +# source: resources/sample_dax_expressions/calc_column_dimCountries_Owner.txt +gato_obelix_function(dimCountries[country_name_full]) +--- +# source: resources/sample_dax_expressions/calc_column_dimWeather_dts_Datetime.txt +CALCULATE(MIN(factWeather[Datetime]),FILTER(factWeather,factWeather[Datetime slicer]=dimWeather_dts[DT AUX])) +--- +# source: resources/sample_dax_expressions/calc_column_factWeather_Datetime slicer.txt +IF(factWeather[Forecast]="Forecast",CONVERT(factWeather[Datetime],STRING),"Now") +--- +# source: resources/sample_dax_expressions/calc_column_factWeather_parent_column_used_by_unused.txt +rand() +--- +# source: resources/sample_dax_expressions/measure_Year_Year Value.txt +SELECTEDVALUE('Year'[Year], 1700) +--- +# source: resources/sample_dax_expressions/measure__Measures_1950 year.txt +IF([Year Value]<=1950,1950,BLANK()) +--- +# source: resources/sample_dax_expressions/measure__Measures_Actual Temperature (℃).txt +CALCULATE(AVERAGE(factWeather[temp_c]), factWeather[Forecast]="Aktuell") +--- +# source: resources/sample_dax_expressions/measure__Measures_Actual Wind Speed (km_h).txt +0+ CALCULATE(AVERAGE(factWeather[wind_kph]), factWeather[Forecast]="Aktuell",NOT ISBLANK(dimCompassDir[Arrow64])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Average Pressure (mB).txt +AVERAGE(factWeather[pressure_mb]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Average Temperature (℃).txt +AVERAGE(factWeather[temp_c]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Average wind speed (km_h).txt +AVERAGE(factWeather[wind_kph])+0 +--- +# source: resources/sample_dax_expressions/measure__Measures_CondForm.txt +VAR _value = [randomMeasure] +RETURN IF(_value>0,"TriangleHigh","TriangleLow") +--- +# source: resources/sample_dax_expressions/measure__Measures_Conditional color bg max.txt +SWITCH( + [Selected metric], + "Temperature", "#f6b53d", + "Condition", "#f6b53d", + "Pressure","#f6b53d", + "Humidity","#9fb6d1", + "Wind direction","#f6b53d", + "Wind speed","#f6b53d", + "Rain probability","#9fb6d1") +--- +# source: resources/sample_dax_expressions/measure__Measures_Conditional color bg min.txt +SWITCH( + [Selected metric], + "Temperature", "#9fb6d1", + "Condition", "#9fb6d1", + "Pressure","#9fb6d1", + "Humidity","#f6b53d", + "Wind direction","#9fb6d1", + "Wind speed","#9fb6d1", + "Rain probability","#f6b53d") +--- +# source: resources/sample_dax_expressions/measure__Measures_Current year.txt +YEAR(TODAY()) +--- +# source: resources/sample_dax_expressions/measure__Measures_Datetime control.txt +VAR selected_date = SELECTEDVALUE(factWeather[Datetime]) +VAR max_date_all=CALCULATE(MAX(factWeather[Datetime]),ALL(factWeather)) +VAR min_date_all= CALCULATE(MIN(factWeather[Datetime]),ALL(factWeather)) +VAR max_date=MAX(factWeather[Datetime]) +VAR min_date= MIN(factWeather[Datetime]) +VAR actual_datetime = CALCULATE(MIN(factWeather[Datetime]),ALL(factWeather),factWeather[Forecast]="Aktuell") + +var _datetime = + IF( + NOT ISBLANK(selected_date), + selected_date, + IF( + max_date=max_date_all + &&min_date=min_date_all, + actual_datetime,min_date&" - "&max_date)) +return _datetime +--- +# source: resources/sample_dax_expressions/measure__Measures_Direct reference to Measure.txt +'_Measures'[Measure displayed conditional colors] +--- +# source: resources/sample_dax_expressions/measure__Measures_Displayed Icon.txt +SWITCH([Selected metric], + "Condition",[Weather Icon], + "Wind direction",[Wind direction Icon], + "Wind speed",[Wind direction Icon]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Exports (Billions of CHF).txt +ROUNDUP([Exports]/10^9,0) +--- +# source: resources/sample_dax_expressions/measure__Measures_Exports LY.txt +CALCULATE([Exports],SAMEPERIODLASTYEAR(dimCalendar[Date])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Exports delta.txt +[Exports]-[Exports LY] +--- +# source: resources/sample_dax_expressions/measure__Measures_Exports.txt +CALCULATE(SUM(factTrades[Value (thousands USD)]),factTrades[Export]="Export") +--- +# source: resources/sample_dax_expressions/measure__Measures_FahrenheitConstant.txt +32 +--- +# source: resources/sample_dax_expressions/measure__Measures_Filter with Measure _ Value.txt +CALCULATE(MAX(factWeather[temp_c]),FILTER(factWeather,[Average wind speed (km/h)]>5)) +--- +# source: resources/sample_dax_expressions/measure__Measures_Icon width_height.txt +VAR wind_normalized = + DIVIDE( + ([Average wind speed (km/h)]-[Min wind speed]), + ([Max wind speed]-[Min wind speed]))*20+7 +RETURN + +SWITCH([Selected metric], + "Condition",45, + "Wind direction",15, + "Wind speed",wind_normalized) +--- +# source: resources/sample_dax_expressions/measure__Measures_Imports (Billions of CHF).txt +ROUNDUP([Imports]/10^9,0) +--- +# source: resources/sample_dax_expressions/measure__Measures_Imports LY.txt +CALCULATE([Imports],SAMEPERIODLASTYEAR(dimCalendar[Date])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Imports delta.txt +[Imports]-[Imports LY] +--- +# source: resources/sample_dax_expressions/measure__Measures_Imports.txt +CALCULATE(SUM(factTrades[Value (thousands USD)]),factTrades[Export]="Import") +--- +# source: resources/sample_dax_expressions/measure__Measures_JackTest2.txt +VAR HDI_Value = [Human Development Index] //The measure that will be used +VAR radius = 16 // Radius of the donut chart +VAR strokeWidth = 6 // width of the chart +VAR backgroundColor = "%23686868" //Unfilled bg of the donut +VAR textColor = "%23ffffff" // White text color for contrast (encoded # as %23 to make it work) + +//Conditional color logic for the donut chart +VAR foregroundColor = + IF(HDI_Value < 0.55, "%23FF6F61", // Red for bad HDI (< 0.55) + IF(HDI_Value < 0.70, "%23FCB714", //Yellow for average HDI (0.55 <= HDI < 0.70) + "%230EB194")) // Green for good HDI (>= 0.70) + +VAR circumference = 2 * PI() * radius +VAR strokeDasharray = circumference +VAR strokeDashoffset = circumference * (1 - HDI_Value) + +VAR svg = + "data:image/svg+xml;utf8, + + + " & FORMAT(HDI_Value, "0.000") & " + " + +-- Return blank if HDI is blank, otherwise return the SVG +RETURN IF(ISBLANK(HDI_Value), BLANK(), svg) +--- +# source: resources/sample_dax_expressions/measure__Measures_Kanton card conditional.txt +IF([Kanton averages text tooltip]=BLANK(),1,0) +--- +# source: resources/sample_dax_expressions/measure__Measures_Last update dt.txt +CALCULATE(MIN(factWeather[Datetime]),factWeather[Forecast]="Aktuell") +--- +# source: resources/sample_dax_expressions/measure__Measures_Map overlay.txt +VAR _key = "ba65bf7ce74616797f7b2055aea2596e" +VAR _type = + SWITCH( + [Selected metric], + "Temperature", "temp_new", + "Condition", "temp_new", + "Pressure","pressure_new", + "Humidity","clouds_new", + "Wind direction","wind_new", + "Wind speed","wind_new", + "Rain probability","precipitation_new") +RETURN + "https://tile.openweathermap.org/map/"&_type&"/{z}/{x}/{y}.png?appid="&_key +--- +# source: resources/sample_dax_expressions/measure__Measures_Max wind speed.txt +CALCULATE(MAX(factWeather[wind_kph]),ALLSELECTED(factWeather)) +--- +# source: resources/sample_dax_expressions/measure__Measures_Measure with IFERROR and division without DIVIDE.txt +IFERROR( 1/0, "Mamma Mia!") +--- +# source: resources/sample_dax_expressions/measure__Measures_Measure with USERALTIONSHIP in a Table with RLS.txt +CALCULATE(COUNTROWS(trCountries),USERELATIONSHIP(dimCountries[country_code],trCountries[Country])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Measure with date hierarchy.txt +CALCULATE( + SUM(factPopulation[Population (historical estimates and future projections)]), + factPopulation[Date].[Year] = 2021) +--- +# source: resources/sample_dax_expressions/measure__Measures_Measure.txt +SUMX(dimKantone,IF(dimKantone[Kanton]="ZUG",BLANK(),2)) +--- +# source: resources/sample_dax_expressions/measure__Measures_Min wind speed.txt +CALCULATE(MIN(factWeather[wind_kph]),ALLSELECTED(factWeather)) +--- +# source: resources/sample_dax_expressions/measure__Measures_Now.txt +NOW() +--- +# source: resources/sample_dax_expressions/measure__Measures_Population Age 0-5.txt +VAR pop= + CALCULATE( + SUM(factAgeGroups[Population]), + dimAgeGroups[Age Group]="0-5") +RETURN +IF(ISBLANK(pop),"No Data",pop/SUM(factAgeGroups[Population])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population Age 15-24.txt +VAR pop= + CALCULATE( + SUM(factAgeGroups[Population]), + dimAgeGroups[Age Group]="15-24") +RETURN +IF(ISBLANK(pop),"No Data",pop/SUM(factAgeGroups[Population])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population Age 25-64.txt +VAR pop= + CALCULATE( + SUM(factAgeGroups[Population]), + dimAgeGroups[Age Group]="25-64") +RETURN +IF(ISBLANK(pop),"No Data",pop/SUM(factAgeGroups[Population])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population Age 5-14.txt +VAR pop= + CALCULATE( + SUM(factAgeGroups[Population]), + dimAgeGroups[Age Group]="5-14") +RETURN +IF(ISBLANK(pop),"No Data",pop/SUM(factAgeGroups[Population])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population Age 65+.txt +VAR pop= + CALCULATE( + SUM(factAgeGroups[Population]), + dimAgeGroups[Age Group]="65+") +RETURN +IF(ISBLANK(pop),"No Data",pop/SUM(factAgeGroups[Population])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population Gapminder.txt +CALCULATE( + SUM(factPopulation[Population (historical estimates and future projections)]), + FILTER(factPopulation,factPopulation[Year]>'Year'[Year Value])) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population PBI.txt +CALCULATE( + SUM(factPopulation[Population (historical estimates and future projections)]), + FILTER(factPopulation,factPopulation[Year]<=YEAR(TODAY())&&factPopulation[Year]>[Year Value]) + + ) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population by Age Group 2.txt +sum(factAgeGroups[Population]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population by Age Group.txt +SUM(factAgeGroups[Population]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Population forecast PBI source title.txt +[Population by year title]&" - Forecast "&[Population source PBI] +--- +# source: resources/sample_dax_expressions/measure__Measures_Population forecast gapminder source title.txt +[Population by year title]&" - Forecast "&[Population source] +--- +# source: resources/sample_dax_expressions/measure__Measures_Rain probability (%).txt +AVERAGE(factWeather[chance_of_rain]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Relative humidity (%).txt +AVERAGE(factWeather[humidity]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Selected Language.txt +SELECTEDVALUE(trLanguages[Language]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Selected Map Background.txt +SELECTEDVALUE(MapBackgrounds[URL],"https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png") +--- +# source: resources/sample_dax_expressions/measure__Measures_Selected metric.txt +SELECTEDVALUE(dimMetrics[Metric]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Selected translated metric.txt +SELECTEDVALUE(trMetrics[Metric]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Size axis metric.txt +SELECTEDVALUE('trMetrics_size'[Original]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Tooltip text.txt +"Tooltip" +--- +# source: resources/sample_dax_expressions/measure__Measures_Total Trade.txt +SUM(factTrades[Value (thousands USD)]) +--- +# source: resources/sample_dax_expressions/measure__Measures_TotalSales.txt +SUM ( factWeather[temp_c]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Treta.txt +"TT Page 1" +--- +# source: resources/sample_dax_expressions/measure__Measures_Weather Icon.txt +VAR _date = SELECTEDVALUE(factWeather[Date]) + +VAR actual_icon= + CALCULATE( + FIRSTNONBLANK(factWeather[condition.icon],TRUE()), + factWeather[Forecast]="Aktuell" + ) + +VAR _icon= + CALCULATE( + FIRSTNONBLANK(factWeather[condition.icon],TRUE()), + factWeather[Datetime]=MIN(factWeather[Datetime]) + ) +RETURN + +IF(ISBLANK(_date),actual_icon,_icon) +--- +# source: resources/sample_dax_expressions/measure__Measures_Wind direction Icon.txt +CALCULATE( + FIRSTNONBLANK(dimCompassDir[Arrow64],TRUE()), + factWeather[Datetime]=MIN(factWeather[Datetime]) + ) +--- +# source: resources/sample_dax_expressions/measure__Measures_X axis metric.txt +SELECTEDVALUE('trMetrics_X'[Original]) +--- +# source: resources/sample_dax_expressions/measure__Measures_Y axis metric.txt +SELECTEDVALUE('trMetrics_Y'[Original]) +--- +# source: resources/sample_dax_expressions/measure__Measures_another_unused.txt +SUM(factWeather[parent_column_used_by_unused]) +--- +# source: resources/sample_dax_expressions/measure__Measures_child_used_by_unused.txt +[parent_used_by_unused]+SUM(factWeather[parent_column_used_by_unused]) +--- +# source: resources/sample_dax_expressions/measure__Measures_child_used_by_unused_2.txt +sum(factWeather[parent_column_used_by_unused]) + 3 +--- +# source: resources/sample_dax_expressions/measure__Measures_grand_child_unused.txt +[child_used_by_unused]*2 +--- +# source: resources/sample_dax_expressions/measure__Measures_grand_child_unused_2.txt +[child_used_by_unused] + [child_used_by_unused_2] +--- +# source: resources/sample_dax_expressions/measure__Measures_parent_used_by_unused.txt +RAND() +--- +# source: resources/sample_dax_expressions/measure__Measures_randomMeasure.txt +RANDBETWEEN(-1,1) +--- +# source: resources/sample_dax_expressions/table_DimYears.txt +DISTINCT(factPopulation[Year]) +--- +# source: resources/sample_dax_expressions/table_Field parameters 2.txt +{ + ("Relative humidity (%)", NAMEOF('_Measures'[Relative humidity (%)]), 0), + ("Rain probability (%)", NAMEOF('_Measures'[Rain probability (%)]), 1), + ("Measure displayed charts", NAMEOF('_Measures'[Measure displayed charts]), 2), + ("Average wind speed (km/h)", NAMEOF('_Measures'[Average wind speed (km/h)]), 3) +} +--- +# source: resources/sample_dax_expressions/table_Table 2.txt +INFO.PARTITIONS() +--- +# source: resources/sample_dax_expressions/table_Table.txt +trCountries +--- +# source: resources/sample_dax_expressions/table_UDF generated table.txt +create_decades_table(dimCalendar) +--- +# source: resources/sample_dax_expressions/table_Year.txt +GENERATESERIES(100, 2100, 1) +--- +# source: resources/sample_dax_expressions/table_dimAgeGroups.txt +ADDCOLUMNS( + DISTINCT(factAgeGroups[Age Group]), + "Age_ID", + SWITCH( + [Age Group], + "0-5",1, + "5-14",2, + "15-24",3, + "25-64",4, + "65+",5 + ) + ) +--- +# source: resources/sample_dax_expressions/table_dimCalendar.txt +ADDCOLUMNS(CALENDAR(MIN(facttrades[Date]),MAX(factWeather[Date])),"Year",YEAR([Date])) +--- +# source: resources/sample_dax_expressions/table_dimWeather_dts.txt +DISTINCT(SELECTCOLUMNS(factWeather,"Datetime slicer",[Datetime slicer],"DT AUX",[Datetime slicer])) +--- +# source: resources/sample_dax_expressions/table_factCopy.txt +factWeather +--- +# source: resources/sample_dax_expressions/table_trLanguages.txt +DISTINCT(trMetrics[Language]) +--- +# source: tests/test_use_treatas_instead_of_intersect.py +INTERSECT(Table1, Table2) +--- +# source: tests/test_use_treatas_instead_of_intersect.py +TREATAS(Table1, Table2) +--- +# source: tests/test_use_treatas_instead_of_intersect.py +INTERSECT(Table1, Table2) + INTERSECT(Table3, Table4) +--- +# source: tests/test_use_treatas_instead_of_intersect.py +VAR x = INTERSECT(Table1, Table2) RETURN x +--- +# source: tests/test_use_treatas_instead_of_intersect.py +TREATAS(Table1[ColumnA], Table2[ColumnB]) +--- +# source: tests/test_evaluatelog_rule.py +SUM(factWeather[temp_c]) +--- +# source: tests/test_evaluatelog_rule.py +EVALUATEANDLOG([Measure1]) + EVALUATEANDLOG([Measure2]) +--- +# source: tests/test_use_divide.py +1 / 0 +--- +# source: tests/test_use_divide.py +DIVIDE(1, 0) +--- +# source: tests/test_use_divide.py +1 / 0 + 2 / 0 +--- +# source: tests/test_use_divide.py +VAR x = 1 / 0 RETURN x +--- +# source: tests/test_use_divide.py +DIVIDE(Sales[Amount], Sales[Total]) +--- +# source: tests/test_use_divide.py +1 / 0 + DIVIDE(Sales[Amount], Sales[Total]) +--- +# source: tests/test_userelationship_references.py +CALCULATE(COUNTROWS(trCountries), USERELATIONSHIP(dimCountries[country_code], trCountries[Country])) +--- +# source: tests/test_userelationship_references.py +USERELATIONSHIP(trCountries[Country], dimCountries[country_code]) +--- +# source: tests/test_userelationship_references.py +USERELATIONSHIP(trCountries[Country],/*comment here*/ dimCountries[country_code]) +--- +# source: tests/test_userelationship_references.py +CALCULATE( [Some Measure], USERELATIONSHIP('Dim Date'[Date], 'Fact Sales'[Date]), USERELATIONSHIP(Inventory[ProductId], 'Dim Product'[ProductId])) +--- +# source: tests/test_userelationship_references.py +VAR x = CALCULATE( [Total Sales], USERELATIONSHIP('Dim Date'[Date], 'Fact Sales'[OrderDate]), FILTER('Fact Sales', 'Fact Sales'[Amount] > 0)) RETURN x +--- +# source: tests/test_avoid_if_error.py +IFERROR(1/0, 0) +--- +# source: tests/test_avoid_if_error.py +IFERROR(1/0, 0) + IFERROR(2/0, 0) +--- +# source: tests/test_avoid_if_error.py +VAR x = IFERROR(1/0, 0) RETURN x +--- +# source: tests/test_more_dax_cases.py +VAR Sales = SUM(Sales[Amount]) +RETURN Sales +--- +# source: tests/test_more_dax_cases.py +OuterUDF(InnerUDF(1)) +--- +# source: tests/test_more_dax_cases.py +CALCULATE( + SUM('Dim Date'[Year]), + FILTER('Dim Date', 'Dim Date'[Year] > 2020), + VALUES('Dim Date') +) +--- +# source: tests/test_more_dax_cases.py +CALCULATETABLE( + VALUES(DimCustomer[CustomerKey]), + TREATAS(MyUDFTable(), DimCustomer[CustomerKey]) +) +--- +# source: tests/test_more_dax_cases.py +VAR a = SUMX(VALUES(DimProduct[Category]), [Measure]) +VAR b = DIVIDE(a, COUNTROWS(DimProduct)) +RETURN a + b +--- +# source: tests/test_more_dax_cases.py +SUMX(MyTableUDF(), [Value]) +--- +# source: tests/test_avoid_one_minus_division.py +1 - (Sales[Amount] / Sales[Total]) +--- +# source: tests/test_avoid_one_minus_division.py +1 - (Sales[Amount] / Sales[Total]) + 1 + (Profit[Value] / Profit[Total]) +--- +# source: tests/test_avoid_one_minus_division.py +VAR x = 1 - (Sales[Amount] / Sales[Total]) RETURN x +--- +# source: tests/test_avoid_one_minus_division.py +1 + (Sales[Amount] / Sales[Total]) +--- +# source: tests/test_variables_and_functions.py +VAR x = COUNTROWS(DimCustomer) VAR y = x + 1 RETURN y +--- +# source: tests/test_variables_and_functions.py +MyUDF(1) + 2 +--- +# source: tests/test_variables_and_functions.py +CALCULATE(SUM(factTrades[Value]), FILTER(factTrades, factTrades[Export]="Import")) +--- +# source: main.py +UDF_model_extension_dependencies() + diff --git a/tests/dax/fixtures/pydaxlexer/stress.txt b/tests/dax/fixtures/pydaxlexer/stress.txt new file mode 100644 index 00000000..149c6f02 --- /dev/null +++ b/tests/dax/fixtures/pydaxlexer/stress.txt @@ -0,0 +1,69 @@ +# source: resources/sample_dax_expressions/measure__Measures_Datetime control.txt +VAR selected_date = SELECTEDVALUE(factWeather[Datetime]) +VAR max_date_all=CALCULATE(MAX(factWeather[Datetime]),ALL(factWeather)) +VAR min_date_all= CALCULATE(MIN(factWeather[Datetime]),ALL(factWeather)) +VAR max_date=MAX(factWeather[Datetime]) +VAR min_date= MIN(factWeather[Datetime]) +VAR actual_datetime = CALCULATE(MIN(factWeather[Datetime]),ALL(factWeather),factWeather[Forecast]="Aktuell") + +var _datetime = + IF( + NOT ISBLANK(selected_date), + selected_date, + IF( + max_date=max_date_all + &&min_date=min_date_all, + actual_datetime,min_date&" - "&max_date)) +return _datetime +--- +# source: resources/sample_dax_expressions/measure__Measures_JackTest2.txt +VAR HDI_Value = [Human Development Index] //The measure that will be used +VAR radius = 16 // Radius of the donut chart +VAR strokeWidth = 6 // width of the chart +VAR backgroundColor = "%23686868" //Unfilled bg of the donut +VAR textColor = "%23ffffff" // White text color for contrast (encoded # as %23 to make it work) + +//Conditional color logic for the donut chart +VAR foregroundColor = + IF(HDI_Value < 0.55, "%23FF6F61", // Red for bad HDI (< 0.55) + IF(HDI_Value < 0.70, "%23FCB714", //Yellow for average HDI (0.55 <= HDI < 0.70) + "%230EB194")) // Green for good HDI (>= 0.70) + +VAR circumference = 2 * PI() * radius +VAR strokeDasharray = circumference +VAR strokeDashoffset = circumference * (1 - HDI_Value) + +VAR svg = + "data:image/svg+xml;utf8, + + + " & FORMAT(HDI_Value, "0.000") & " + " + +-- Return blank if HDI is blank, otherwise return the SVG +RETURN IF(ISBLANK(HDI_Value), BLANK(), svg) +--- +# source: resources/sample_dax_expressions/measure__Measures_Map overlay.txt +VAR _key = "ba65bf7ce74616797f7b2055aea2596e" +VAR _type = + SWITCH( + [Selected metric], + "Temperature", "temp_new", + "Condition", "temp_new", + "Pressure","pressure_new", + "Humidity","clouds_new", + "Wind direction","wind_new", + "Wind speed","wind_new", + "Rain probability","precipitation_new") +RETURN + "https://tile.openweathermap.org/map/"&_type&"/{z}/{x}/{y}.png?appid="&_key +--- +# source: resources/sample_dax_expressions/measure__Measures_Population PBI.txt +CALCULATE( + SUM(factPopulation[Population (historical estimates and future projections)]), + FILTER(factPopulation,factPopulation[Year]<=YEAR(TODAY())&&factPopulation[Year]>[Year Value]) + + ) diff --git a/tests/dax/fixtures/query-docs/LICENSE b/tests/dax/fixtures/query-docs/LICENSE new file mode 100644 index 00000000..e056e7c3 --- /dev/null +++ b/tests/dax/fixtures/query-docs/LICENSE @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. \ No newline at end of file diff --git a/tests/dax/fixtures/query-docs/LICENSE-CODE b/tests/dax/fixtures/query-docs/LICENSE-CODE new file mode 100644 index 00000000..b17b032a --- /dev/null +++ b/tests/dax/fixtures/query-docs/LICENSE-CODE @@ -0,0 +1,17 @@ +The MIT License (MIT) +Copyright (c) Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/tests/dax/fixtures/query-docs/queries.txt b/tests/dax/fixtures/query-docs/queries.txt new file mode 100644 index 00000000..006cb473 --- /dev/null +++ b/tests/dax/fixtures/query-docs/queries.txt @@ -0,0 +1,87 @@ +# source: query-docs/query-languages/dax/dax-queries.md (EVALUATE example) +EVALUATE + 'Sales Order' +--- +# source: query-docs/query-languages/dax/dax-queries.md (ORDER BY example) +EVALUATE + SUMMARIZECOLUMNS( + // Group by columns + 'Date'[Month Name], + 'Date'[Month of Year], + 'Product'[Category], + + // Optional filters + FILTER( + VALUES('Product'[Category]), + [Category] = "Clothing" + ), + + // Measures or explicit DAX formulas to aggregate and analyze the data by row + "Orders", [Orders], + "Avg Profit per Order", DIVIDE( + [Total Sales Profit], + [Orders] + ) + ) + + // DAX queries do not use sort order defined in Power BI, + // sort by columns must be included in the DAX query to be used in order by + ORDER BY 'Date'[Month of Year] ASC +--- +# source: query-docs/query-languages/dax/dax-queries.md (TOPN ORDER BY example) +EVALUATE + TOPN( + 100, + 'Sales Order', + // The way the data is sorted before the top 100 rows are selected + 'Sales Order'[SalesOrderLineKey], ASC + ) + // The way the data is sorted for the results + ORDER BY + 'Sales Order'[Sales Order] ASC, + 'Sales Order'[Sales Order Line] ASC +--- +# source: query-docs/query-languages/dax/dax-queries.md (START AT example) +EVALUATE + 'Sales Order' + ORDER BY 'Sales Order'[Sales Order] ASC + // Start at this order, orders before this order will not be displayed + START AT "SO43661" +--- +# source: query-docs/query-languages/dax/dax-queries.md (DEFINE example) +DEFINE + VAR _firstyear = MIN('Date'[Fiscal Year]) + VAR _lastyear = MAX('Date'[Fiscal Year]) + TABLE 'Unbought products' = FILTER('Product', [Orders] + 0 = 0) + COLUMN 'Unbought products'[Year Range] = _firstyear & " - " & _lastyear + MEASURE 'Unbought products'[Unbought products] = COUNTROWS('Unbought products') + +EVALUATE + 'Unbought products' + +EVALUATE + {[Unbought products]} +--- +# source: query-docs/query-languages/dax/dax-queries.md (DEFINE MEASURE example) +DEFINE + MEASURE 'Pick a sales measure'[Orders] = DISTINCTCOUNT('Sales Order'[Sales Order]) + MEASURE 'Pick a sales measure'[Customers] = CALCULATE( + COUNTROWS(Customer), + FILTER( + 'Sales', + [Orders] > 0 + ) + ) + MEASURE 'Pick a sales measure'[Orders per Customer] = DIVIDE( + [Orders], + [Customers], + 0 + ) + +EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Orders", [Orders], + "Customers", [Customers], + "Orders per Customer", [Orders per Customer] + ) diff --git a/tests/dax/fixtures/tabulareditor/LICENSE b/tests/dax/fixtures/tabulareditor/LICENSE new file mode 100644 index 00000000..dade3ed4 --- /dev/null +++ b/tests/dax/fixtures/tabulareditor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Tabular Editor ApS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/dax/fixtures/tabulareditor/keyword_functions.txt b/tests/dax/fixtures/tabulareditor/keyword_functions.txt new file mode 100644 index 00000000..679c00b0 --- /dev/null +++ b/tests/dax/fixtures/tabulareditor/keyword_functions.txt @@ -0,0 +1,23 @@ +DEFINE +EVALUATE +ORDER +BY +START +AT +RETURN +FILTER +CALCULATE +CALCULATETABLE +SWITCH +SUMMARIZECOLUMNS +SELECTCOLUMNS +ADDCOLUMNS +TOPN +ALL +VALUES +BETA.DIST +TOTALYTD +USERELATIONSHIP +IF +SUMX +COUNTROWS diff --git a/tests/dax/fixtures/tabulareditor/keywords.txt b/tests/dax/fixtures/tabulareditor/keywords.txt new file mode 100644 index 00000000..2d44964f --- /dev/null +++ b/tests/dax/fixtures/tabulareditor/keywords.txt @@ -0,0 +1,392 @@ +ABS +ACOS +ACOSH +ACOT +ACOTH +ADDCOLUMNS +ADDMISSINGITEMS +ALL +ALLCROSSFILTERED +ALLEXCEPT +ALLNOBLANKROW +ALLSELECTED +AND +APPROXIMATEDISTINCTCOUNT +ASIN +ASINH +ATAN +ATANH +AVERAGE +AVERAGEA +AVERAGEX +BETA.DIST +BETA.INV +BLANK +CALCULATE +CALCULATETABLE +CALENDAR +CALENDARAUTO +CEILING +CHISQ.DIST +CHISQ.DIST.RT +CHISQ.INV +CHISQ.INV.RT +CLOSINGBALANCEMONTH +CLOSINGBALANCEQUARTER +CLOSINGBALANCEYEAR +COALESCE +COMBIN +COMBINA +COMBINEVALUES +CONCATENATE +CONCATENATEX +CONFIDENCE.NORM +CONFIDENCE.T +CONTAINS +CONTAINSROW +CONTAINSSTRING +CONTAINSSTRINGEXACT +CONVERT +COS +COSH +COT +COTH +COUNT +COUNTA +COUNTAX +COUNTBLANK +COUNTROWS +COUNTX +CROSSFILTER +CROSSJOIN +CURRENCY +CURRENTGROUP +CUSTOMDATA +DATATABLE +DATE +DATEADD +DATEDIFF +DATESBETWEEN +DATESINPERIOD +DATESMTD +DATESQTD +DATESYTD +DATEVALUE +DAY +DEGREES +DETAILROWS +DISTINCT +DISTINCTCOUNT +DISTINCTCOUNTNOBLANK +DIVIDE +EARLIER +EARLIEST +EDATE +ENDOFMONTH +ENDOFQUARTER +ENDOFYEAR +EOMONTH +ERROR +EVEN +EXACT +EXCEPT +EXP +EXPON.DIST +FACT +FALSE +FILTER +FILTERS +FIND +FIRSTDATE +FIRSTNONBLANK +FIRSTNONBLANKVALUE +FIXED +FLOOR +FORMAT +GCD +GENERATE +GENERATEALL +GENERATESERIES +GEOMEAN +GEOMEANX +GROUPBY +HASONEFILTER +HASONEVALUE +HOUR +IF +IF.EAGER +IFERROR +IGNORE +INT +INTERSECT +ISBLANK +ISCROSSFILTERED +ISEMPTY +ISERROR +ISEVEN +ISFILTERED +ISINSCOPE +ISLOGICAL +ISNONTEXT +ISNUMBER +ISO.CEILING +ISODD +ISONORAFTER +ISSELECTEDMEASURE +ISSUBTOTAL +ISTEXT +KEEPFILTERS +KEYWORDMATCH +LASTDATE +LASTNONBLANK +LASTNONBLANKVALUE +LCM +LEFT +LEN +LN +LOG +LOG10 +LOOKUPVALUE +LOWER +MAX +MAXA +MAXX +MEDIAN +MEDIANX +MID +MIN +MINA +MINUTE +MINX +MOD +MONTH +MROUND +NATURALINNERJOIN +NATURALLEFTOUTERJOIN +NEXTDAY +NEXTMONTH +NEXTQUARTER +NEXTYEAR +NONVISUAL +NORM.DIST +NORM.INV +NORM.S.DIST +NORM.S.INV +NOT +NOW +ODD +OPENINGBALANCEMONTH +OPENINGBALANCEQUARTER +OPENINGBALANCEYEAR +OR +PARALLELPERIOD +PATH +PATHCONTAINS +PATHITEM +PATHITEMREVERSE +PATHLENGTH +PERCENTILE.EXC +PERCENTILE.INC +PERCENTILEX.EXC +PERCENTILEX.INC +PERMUT +PI +POISSON.DIST +POWER +PREVIOUSDAY +PREVIOUSMONTH +PREVIOUSQUARTER +PREVIOUSYEAR +PRODUCT +PRODUCTX +QUARTER +QUOTIENT +RADIANS +RAND +RANDBETWEEN +RANK.EQ +RANKX +RELATED +RELATEDTABLE +REMOVEFILTERS +REPLACE +REPT +RIGHT +ROLLUP +ROLLUPADDISSUBTOTAL +ROLLUPGROUP +ROLLUPISSUBTOTAL +ROUND +ROUNDDOWN +ROUNDUP +ROW +SAMEPERIODLASTYEAR +SAMPLE +SEARCH +SECOND +SELECTCOLUMNS +SELECTEDMEASURE +SELECTEDMEASUREFORMATSTRING +SELECTEDMEASURENAME +SELECTEDVALUE +SIGN +SIN +SINH +SQRT +SQRTPI +STARTOFMONTH +STARTOFQUARTER +STARTOFYEAR +STDEV.P +STDEV.S +STDEVX.P +STDEVX.S +SUBSTITUTE +SUBSTITUTEWITHINDEX +SUM +SUMMARIZE +SUMMARIZECOLUMNS +SUMX +SWITCH +T.DIST +T.DIST.2T +T.DIST.RT +T.INV +T.INV.2T +TAN +TANH +TIME +TIMEVALUE +TODAY +TOPN +TOPNPERLEVEL +TOPNSKIP +TOTALMTD +TOTALQTD +TOTALYTD +TREATAS +TRIM +TRUE +TRUNC +UNICHAR +UNICODE +UNION +UPPER +USERELATIONSHIP +USERNAME +USEROBJECTID +USERPRINCIPALNAME +UTCNOW +UTCTODAY +VALUE +VALUES +VAR.P +VAR.S +VARX.P +VARX.S +WEEKDAY +YEARFRAC +WEEKNUM +XIRR +XNPV +YEAR +ACCRINT +ACCRINTM +AMORDEGRC +AMORLINC +COUPDAYBS +COUPDAYS +COUPDAYSNC +COUPNCD +COUPNUM +COUPPCD +CUMIPMT +CUMPRINC +DB +DDB +DISC +DOLLARDE +DOLLARFR +DURATION +EFFECT +FV +INTRATE +IPMT +ISPMT +MDURATION +NOMINAL +NPER +ODDFPRICE +ODDFYIELD +ODDLPRICE +ODDLYIELD +PDURATION +PMT +PPMT +PRICE +PRICEDISC +PRICEMAT +PV +RATE +RECEIVED +RRI +SLN +SYD +TBILLEQ +TBILLPRICE +TBILLYIELD +VDB +YIELD +YIELDDISC +YIELDMAT +SAMPLEAXISWITHLOCALMINMAX +EVALUATEANDLOG +OFFSET +INDEX +WINDOW +ORDERBY +RANK +ROWNUMBER +PARTITIONBY +EXTERNALMEASURE +KMEANSCLUSTERING +DEFINE +EVALUATE +ORDER +BY +START +AT +RETURN +VAR +IN +ASC +DESC +SKIP +DENSE +BLANKS +LAST +FIRST +WEEK +BOTH +NONE +ONEWAY +ONEWAY_RIGHTFILTERSLEFT +ONEWAY_LEFTFILTERSRIGHT +INTEGER +DOUBLE +STRING +BOOLEAN +DATETIME +VARIANT +TEXT +ALPHABETICAL +KEEP +REL +EXPR +VAL +ANYVAL +ANYREF +SCALAR +INT64 +DECIMAL +NUMERIC diff --git a/tests/dax/test_ast.py b/tests/dax/test_ast.py new file mode 100644 index 00000000..8d51fb4c --- /dev/null +++ b/tests/dax/test_ast.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import sidemantic_dax.ast as dax_ast + + +def test_from_raw_expr_function_call(): + raw = { + "FunctionCall": { + "name": "SUM", + "args": [ + { + "TableColumnRef": { + "table": {"name": "Sales", "quoted": True}, + "column": "Amount", + } + } + ], + } + } + + expr = dax_ast.from_raw_expr(raw) + assert isinstance(expr, dax_ast.FunctionCall) + assert expr.name == "SUM" + assert len(expr.args) == 1 + arg = expr.args[0] + assert isinstance(arg, dax_ast.TableColumnRef) + assert arg.table.name == "Sales" + assert arg.column == "Amount" + + +def test_from_raw_query_define_evaluate(): + raw = { + "define": { + "defs": [ + { + "Measure": { + "doc": None, + "table": {"name": "t", "quoted": True}, + "name": "m", + "expr": {"Number": "1"}, + } + } + ] + }, + "evaluates": [ + { + "expr": {"TableRef": {"name": "t", "quoted": True}}, + "order_by": [{"expr": {"BracketRef": "m"}, "direction": "Desc"}], + "start_at": [{"Number": "5"}], + } + ], + } + + query = dax_ast.from_raw_query(raw) + assert query.define is not None + assert len(query.define.defs) == 1 + definition = query.define.defs[0] + assert isinstance(definition, dax_ast.MeasureDef) + assert definition.name == "m" + assert isinstance(query.evaluates[0].order_by[0].direction, dax_ast.SortDirection) + + +def test_from_raw_expr_parameter(): + expr = dax_ast.from_raw_expr({"Parameter": "p"}) + assert isinstance(expr, dax_ast.Parameter) + assert expr.name == "p" + + +def test_from_raw_expr_hierarchy_ref(): + expr = dax_ast.from_raw_expr( + { + "HierarchyRef": { + "table": {"name": "Fact", "quoted": False}, + "column": "Date", + "levels": ["Year", "Month"], + } + } + ) + assert isinstance(expr, dax_ast.HierarchyRef) + assert expr.table.name == "Fact" + assert expr.column == "Date" + assert expr.levels == ["Year", "Month"] + + +def test_from_raw_definition_function(): + raw = { + "Function": { + "doc": "adds", + "name": "sumtwo", + "params": [{"name": "a", "type_hints": []}, {"name": "b", "type_hints": ["numeric"]}], + "body": {"Identifier": "a"}, + } + } + definition = dax_ast._from_raw_definition(raw) + assert isinstance(definition, dax_ast.FunctionDef) + assert definition.name == "sumtwo" + assert len(definition.params) == 2 + + +def test_from_raw_tokens(): + raw = [ + {"kind": {"Ident": "sum"}, "span": {"start": 0, "end": 3}}, + {"kind": "LParen", "span": {"start": 3, "end": 4}}, + {"kind": {"Number": "1"}, "span": {"start": 4, "end": 5}}, + {"kind": "RParen", "span": {"start": 5, "end": 6}}, + {"kind": "Eof", "span": {"start": 6, "end": 6}}, + ] + + tokens = dax_ast.from_raw_tokens(raw) + assert len(tokens) == 5 + assert isinstance(tokens[0].kind, dax_ast.IdentToken) + assert isinstance(tokens[-1].kind, dax_ast.Eof) diff --git a/tests/dax/test_external_powerbi_fixtures.py b/tests/dax/test_external_powerbi_fixtures.py new file mode 100644 index 00000000..232cc80b --- /dev/null +++ b/tests/dax/test_external_powerbi_fixtures.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import sidemantic_dax + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "fixtures" / "external_powerbi" / "marfolger-powerbi-dax" / "business_logic_DAX.txt" + + +def _parse_expression(expression: str): + try: + return sidemantic_dax.parse_expression(expression) + except RuntimeError as exc: + if "native module is not available" in str(exc): + pytest.skip("sidemantic_dax native module not available") + raise + + +def _load_measure_assignments(path: Path) -> list[tuple[str, str]]: + measures: list[tuple[str, str]] = [] + current_name: str | None = None + current_lines: list[str] = [] + + for line in path.read_text().splitlines(): + if not line.strip() or line.lstrip().startswith("//"): + continue + match = re.match(r"^([^=]+?)\s=\s*$", line) + if match and not line.startswith((" ", "\t")): + if current_name is not None: + measures.append((current_name, "\n".join(current_lines).strip())) + current_name = match.group(1).strip() + current_lines = [] + continue + if current_name is not None: + current_lines.append(line) + + if current_name is not None: + measures.append((current_name, "\n".join(current_lines).strip())) + + return measures + + +def test_external_powerbi_dax_measure_file_parses(): + measures = _load_measure_assignments(FIXTURE) + + assert [name for name, _expr in measures] == [ + "Total Revenue", + "Revenue MoM Growth %", + "Avg Turnaround Days", + "Is High Value Client", + "Pickup Preference %", + ] + + for name, expression in measures: + parsed = _parse_expression(expression) + assert parsed is not None, f"failed to parse {name}" diff --git a/tests/dax/test_model_authoring.py b/tests/dax/test_model_authoring.py new file mode 100644 index 00000000..d94e20f2 --- /dev/null +++ b/tests/dax/test_model_authoring.py @@ -0,0 +1,748 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest +import yaml + +from sidemantic import SemanticLayer +from sidemantic.adapters.sidemantic import SidemanticAdapter +from sidemantic.core.introspection import describe_graph +from sidemantic.dax.modeling import DaxModelingError + +pytest.importorskip("sidemantic_dax") + + +def _write_native_dax_model(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: category + type: categorical + - name: doubled_amount + type: numeric + dax: "'sales'[amount] * 2" + metrics: + - name: revenue + dax: "SUM('sales'[amount])" +""" + ) + return path + + +def test_native_sidemantic_dax_authoring_lowers_and_preserves_source(tmp_path): + layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) + sales = layer.get_model("sales") + + doubled = sales.get_dimension("doubled_amount") + assert doubled.sql == "(amount * 2)" + assert doubled.dax == "'sales'[amount] * 2" + assert doubled.expression_language == "dax" + + revenue = sales.get_metric("revenue") + assert revenue.agg == "sum" + assert revenue.sql == "amount" + assert revenue.dax == "SUM('sales'[amount])" + assert revenue.expression_language == "dax" + + sidemantic_sql = layer.compile(metrics=["sales.revenue"], dimensions=["sales.category"]) + assert "SUM(sales_cte.revenue_raw)" in sidemantic_sql + + +def test_native_sidemantic_expression_language_dax_uses_sql_text_as_dax_source(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + metrics: + - name: revenue + expression_language: dax + sql: "SUM('sales'[amount])" +""" + ) + + layer = SemanticLayer.from_yaml(path) + revenue = layer.get_model("sales").get_metric("revenue") + + assert revenue.agg == "sum" + assert revenue.sql == "amount" + assert revenue.dax == "SUM('sales'[amount])" + assert revenue.expression_language == "dax" + + +def test_native_sidemantic_public_false_round_trips_for_model_items(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: internal_category + type: categorical + sql: category + public: false + metrics: + - name: internal_revenue + dax: "SUM('sales'[amount])" + public: false +metrics: + - name: global_internal + type: derived + sql: "1" + public: false +""" + ) + + graph = SidemanticAdapter().parse(path) + sales = graph.models["sales"] + assert sales.get_dimension("internal_category").public is False + assert sales.get_metric("internal_revenue").public is False + assert graph.metrics["global_internal"].public is False + + output = tmp_path / "exported.yml" + SidemanticAdapter().export(graph, output) + exported = yaml.safe_load(output.read_text()) + exported_dimension = exported["models"][0]["dimensions"][0] + exported_metric = exported["models"][0]["metrics"][0] + exported_graph_metric = exported["metrics"][0] + assert exported_dimension["public"] is False + assert exported_metric["public"] is False + assert exported_graph_metric["public"] is False + + +def test_native_sidemantic_graph_metric_expression_language_dax_lowers_and_exports(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: amount + type: numeric +metrics: + - name: revenue + expression_language: dax + sql: "SUM('sales'[amount])" +""" + ) + + graph = SidemanticAdapter().parse(path) + revenue = graph.metrics["revenue"] + + assert revenue.agg == "sum" + assert revenue.sql == "amount" + assert revenue.dax == "SUM('sales'[amount])" + assert revenue.expression_language == "dax" + assert getattr(revenue, "_dax_lowered") is True + + output = tmp_path / "exported.yml" + SidemanticAdapter().export(graph, output) + exported = yaml.safe_load(output.read_text()) + exported_metric = exported["metrics"][0] + assert exported_metric["dax"] == "SUM('sales'[amount])" + assert exported_metric["expression_language"] == "dax" + assert "sql" not in exported_metric + + +def test_native_sidemantic_graph_metric_expression_language_dax_requires_single_model_context(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + - name: returns + table: returns + primary_key: id +metrics: + - name: revenue + expression_language: dax + sql: "SUM('sales'[amount])" +""" + ) + + with pytest.raises(DaxModelingError, match="DAX graph metric 'revenue' needs a model context"): + SidemanticAdapter().parse(path) + + +def test_native_sidemantic_expression_language_dax_for_dimensions_and_models(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: amount_doubled + type: numeric + expression_language: dax + sql: "'sales'[amount] * 2" + - name: positive_sales + primary_key: id + expression_language: dax + sql: "FILTER('sales', 'sales'[amount] > 0)" + dimensions: + - name: id + type: numeric +""" + ) + + graph = SidemanticAdapter().parse(path) + amount_doubled = graph.models["sales"].get_dimension("amount_doubled") + positive_sales = graph.models["positive_sales"] + + assert amount_doubled.sql == "(amount * 2)" + assert amount_doubled.dax == "'sales'[amount] * 2" + assert amount_doubled.expression_language == "dax" + assert positive_sales.sql == "SELECT * FROM sales WHERE (amount > 0)" + assert positive_sales.dax == "FILTER('sales', 'sales'[amount] > 0)" + assert positive_sales.expression_language == "dax" + + +def test_native_sidemantic_model_level_dax_calculated_table_lowers(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: id + type: numeric + - name: amount + type: numeric + - name: positive_sales + primary_key: id + dax: "FILTER('sales', 'sales'[amount] > 0)" + dimensions: + - name: id + type: numeric +""" + ) + + graph = SidemanticAdapter().parse(path) + positive_sales = graph.models["positive_sales"] + assert positive_sales.sql == "SELECT * FROM sales WHERE (amount > 0)" + assert positive_sales.table is None + assert positive_sales.dax == "FILTER('sales', 'sales'[amount] > 0)" + assert positive_sales.expression_language == "dax" + assert getattr(positive_sales, "_dax_required_models") == ["sales"] + + description = describe_graph(graph) + positive_sales_info = next(model for model in description["models"] if model["name"] == "positive_sales") + assert positive_sales_info["kind"] == "calculated_table" + assert positive_sales_info["calculated_table"] is True + assert positive_sales_info["dax"] == "FILTER('sales', 'sales'[amount] > 0)" + assert positive_sales_info["original_expression"] == "FILTER('sales', 'sales'[amount] > 0)" + assert positive_sales_info["dax_lowered"] is True + assert positive_sales_info["dax_required_models"] == ["sales"] + + +def test_native_sidemantic_model_level_dax_overrides_table_source(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: id + type: numeric + - name: amount + type: numeric + - name: positive_sales + table: should_not_remain + primary_key: id + expression_language: dax + sql: "FILTER('sales', 'sales'[amount] > 0)" + dimensions: + - name: id + type: numeric +""" + ) + + graph = SidemanticAdapter().parse(path) + positive_sales = graph.models["positive_sales"] + + assert positive_sales.table is None + assert positive_sales.sql == "SELECT * FROM sales WHERE (amount > 0)" + assert positive_sales.dax == "FILTER('sales', 'sales'[amount] > 0)" + assert describe_graph(graph)["models"][1]["kind"] == "calculated_table" + + +def test_native_sidemantic_model_level_dax_surfaces_cross_join_warnings(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: id + type: numeric + - name: products + table: products + primary_key: id + dimensions: + - name: category + type: categorical + - name: sales_products + primary_key: id + dax: "SUMMARIZECOLUMNS(sales[id], products[category])" + dimensions: + - name: id + type: numeric +""" + ) + + graph = SidemanticAdapter().parse(path) + warnings = getattr(graph, "import_warnings") + + assert graph.models["sales_products"].sql == ( + "SELECT sales.id, products.category FROM sales CROSS JOIN products GROUP BY sales.id, products.category" + ) + assert warnings == [ + { + "code": "dax_unrelated_cross_join", + "context": "calculated_table", + "model": "sales_products", + "name": "sales_products", + "message": ( + "DAX query cross joins unrelated table 'products' with 'sales' because no relationship path is defined" + ), + } + ] + + +def test_native_sidemantic_export_preserves_dax_sources(tmp_path): + layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) + output = tmp_path / "exported.yml" + + SidemanticAdapter().export(layer.graph, output) + + exported = yaml.safe_load(output.read_text()) + sales = exported["models"][0] + exported_dimension = next(dim for dim in sales["dimensions"] if dim["name"] == "doubled_amount") + exported_metric = next(metric for metric in sales["metrics"] if metric["name"] == "revenue") + + assert exported_dimension["dax"] == "'sales'[amount] * 2" + assert exported_dimension["expression_language"] == "dax" + assert "sql" not in exported_dimension + assert exported_metric["dax"] == "SUM('sales'[amount])" + assert exported_metric["expression_language"] == "dax" + assert "sql" not in exported_metric + + +def test_native_sidemantic_graph_metric_dax_lowers_exports_and_describes(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: category + type: categorical +metrics: + - name: revenue + dax: "SUM('sales'[amount])" +""" + ) + layer = SemanticLayer.from_yaml(path) + + revenue = layer.graph.metrics["revenue"] + assert revenue.agg == "sum" + assert revenue.sql == "amount" + assert revenue.dax == "SUM('sales'[amount])" + assert revenue.expression_language == "dax" + + compiled = layer.compile(metrics=["revenue"], dimensions=["sales.category"]) + assert "SUM(amount) AS revenue" in compiled + + description = layer.describe_models() + graph_metric = description["metrics"][0] + assert graph_metric["name"] == "revenue" + assert graph_metric["source_format"] == "Sidemantic" + assert graph_metric["source_file"] == "models.yml" + assert graph_metric["dax"] == "SUM('sales'[amount])" + assert graph_metric["original_expression"] == "SUM('sales'[amount])" + assert graph_metric["dax_lowered"] is True + assert graph_metric["faithful_lowering"] is True + + output = tmp_path / "exported.yml" + SidemanticAdapter().export(layer.graph, output) + exported = yaml.safe_load(output.read_text()) + assert exported["metrics"] == [ + { + "name": "revenue", + "dax": "SUM('sales'[amount])", + "expression_language": "dax", + "agg": "sum", + } + ] + + +def test_native_sidemantic_dax_authoring_rejects_invalid_dax(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + metrics: + - name: revenue + dax: "SUM(" +""" + ) + + with pytest.raises(DaxModelingError, match="Could not parse DAX metric 'sales.revenue'"): + SidemanticAdapter().parse(path) + + +@pytest.mark.parametrize( + "yaml_text", + [ + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: doubled_amount + type: numeric + expression_language: sql + dax: "'sales'[amount] * 2" +""", + """ +models: + - name: sales + table: sales + primary_key: id + metrics: + - name: revenue + expression_language: sql + dax: "SUM('sales'[amount])" +""", + """ +models: + - name: positive_sales + primary_key: id + expression_language: sql + dax: "FILTER('sales', 'sales'[amount] > 0)" +""", + """ +models: + - name: sales + table: sales + primary_key: id +metrics: + - name: revenue + expression_language: sql + dax: "SUM('sales'[amount])" +""", + ], +) +def test_native_sidemantic_dax_authoring_rejects_dax_source_with_sql_language(tmp_path, yaml_text): + path = tmp_path / "models.yml" + path.write_text(yaml_text) + + with pytest.raises(DaxModelingError, match="defines dax but expression_language='sql'"): + SidemanticAdapter().parse(path) + + +def test_native_sidemantic_dax_authoring_requires_dax_extra(monkeypatch, tmp_path): + import builtins + + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + metrics: + - name: revenue + dax: "SUM('sales'[amount])" +""" + ) + + real_import = builtins.__import__ + + def _block_sidemantic_dax(name, globals=None, locals=None, fromlist=(), level=0): + if name == "sidemantic_dax" or name.startswith("sidemantic_dax."): + raise ImportError("simulated missing sidemantic_dax") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _block_sidemantic_dax) + + with pytest.raises(DaxModelingError, match="sidemantic_dax is required for DAX model definitions"): + SidemanticAdapter().parse(path) + + +@pytest.mark.parametrize( + ("yaml_text", "message"), + [ + ( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: bad_dimension + type: numeric + dax: "SUM(" +""", + "Could not parse DAX dimension 'sales.bad_dimension'", + ), + ( + """ +models: + - name: bad_table + primary_key: id + dax: "FILTER(" +""", + "Could not parse DAX model 'bad_table'", + ), + ], +) +def test_native_sidemantic_dax_authoring_rejects_invalid_dax_at_load_boundaries(tmp_path, yaml_text, message): + path = tmp_path / "models.yml" + path.write_text(yaml_text) + + with pytest.raises(DaxModelingError, match=message): + SidemanticAdapter().parse(path) + + +@pytest.mark.parametrize( + ("yaml_text", "message"), + [ + ( + """ +models: + - name: sales + table: sales + primary_key: id + metrics: + - name: bad_metric + dax: "UNKNOWNFUNC('sales'[amount])" +""", + "DAX metric 'sales.bad_metric' is unsupported", + ), + ( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: bad_dimension + type: numeric + dax: "UNKNOWNFUNC('sales'[amount])" +""", + "DAX dimension 'sales.bad_dimension' is unsupported", + ), + ( + """ +models: + - name: sales + table: sales + primary_key: id + - name: bad_table + primary_key: id + dax: "UNKNOWNTABLEFN('sales')" +""", + "DAX model 'bad_table' is unsupported", + ), + ( + """ +models: + - name: sales + table: sales + primary_key: id + - name: returns + table: returns + primary_key: id +metrics: + - name: bad_graph_metric + dax: "SUM('sales'[amount])" +""", + "DAX graph metric 'bad_graph_metric' needs a model context", + ), + ], +) +def test_native_sidemantic_dax_authoring_rejects_valid_unsupported_dax_at_load_boundaries(tmp_path, yaml_text, message): + path = tmp_path / "models.yml" + path.write_text(yaml_text) + + with pytest.raises(DaxModelingError, match=message): + SidemanticAdapter().parse(path) + + +def test_semantic_layer_compile_and_query_dax_use_model_metrics(tmp_path): + layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) + layer.adapter.execute("CREATE TABLE sales (id INTEGER, category VARCHAR, amount DOUBLE)") + layer.adapter.execute("INSERT INTO sales VALUES (1, 'A', 10), (2, 'A', 5), (3, 'B', 7)") + + dax = """ + EVALUATE + SUMMARIZECOLUMNS( + 'sales'[category], + "Revenue", [revenue] + ) + ORDER BY 'sales'[category] ASC + """ + sql = layer.compile_dax_query(dax) + assert "SUM(sales.amount) AS Revenue" in sql + assert "revenue AS Revenue" not in sql + + rows = layer.query_dax(dax).fetchall() + assert rows == [("A", 15.0), ("B", 7.0)] + + dry_run = layer.run_dax_query(dax, dry_run=True) + assert dry_run == { + "sql": sql, + "rows": [], + "row_count": 0, + "warnings": [], + "import_warnings": [], + } + + payload = layer.run_dax_query(dax) + assert payload["sql"] == sql + assert payload["rows"] == [{"category": "A", "Revenue": 15.0}, {"category": "B", "Revenue": 7.0}] + assert payload["row_count"] == 2 + assert payload["warnings"] == [] + assert payload["import_warnings"] == [] + json.dumps(payload) + + +def test_semantic_layer_dax_query_payload_preserves_translation_warnings(monkeypatch): + layer = SemanticLayer() + graph_warning = {"code": "query_warning", "message": "graph-level warning"} + evaluate_warning = {"code": "evaluate_warning", "message": "evaluate-level warning"} + + monkeypatch.setattr( + layer, + "translate_dax_query", + lambda _dax: SimpleNamespace( + evaluates=[SimpleNamespace(sql="SELECT 1 AS one", warnings=[evaluate_warning])], + warnings=[graph_warning], + ), + ) + + payload = layer.compile_dax_query_payload('EVALUATE ROW("one", 1)') + assert payload == { + "sql": "SELECT 1 AS one", + "warnings": [graph_warning, evaluate_warning], + "import_warnings": [], + } + assert layer.run_dax_query('EVALUATE ROW("one", 1)', dry_run=True)["warnings"] == [ + graph_warning, + evaluate_warning, + ] + + +def test_semantic_layer_dax_query_payload_warns_on_unrelated_cross_join(tmp_path): + path = tmp_path / "models.yml" + path.write_text( + """ +models: + - name: sales + table: sales + primary_key: id + dimensions: + - name: product_key + type: categorical + - name: products + table: products + primary_key: product_key + dimensions: + - name: category + type: categorical +""" + ) + layer = SemanticLayer.from_yaml(path) + + payload = layer.compile_dax_query_payload("EVALUATE SUMMARIZECOLUMNS('sales'[product_key], 'products'[category])") + + assert payload["sql"] == ( + "SELECT sales.product_key, products.category FROM sales CROSS JOIN products " + "GROUP BY sales.product_key, products.category" + ) + assert payload["warnings"] == [ + { + "code": "dax_unrelated_cross_join", + "context": "query", + "base_table": "sales", + "table": "products", + "message": ( + "DAX query cross joins unrelated table 'products' with 'sales' because no relationship path is defined" + ), + } + ] + assert payload["import_warnings"] == [] + + +def test_semantic_layer_describe_models_exposes_dax_metadata(tmp_path): + layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) + + description = layer.describe_models() + sales = description["models"][0] + revenue = next(metric for metric in sales["metrics"] if metric["name"] == "revenue") + doubled = next(dimension for dimension in sales["dimensions"] if dimension["name"] == "doubled_amount") + + assert description["import_warnings"] == [] + assert sales["kind"] == "table" + assert "calculated_table" not in sales + assert sales["source_format"] == "Sidemantic" + assert sales["source_file"] == "models.yml" + assert revenue["dax"] == "SUM('sales'[amount])" + assert revenue["source_format"] == "Sidemantic" + assert revenue["source_file"] == "models.yml" + assert revenue["original_expression"] == "SUM('sales'[amount])" + assert revenue["dax_lowered"] is True + assert revenue["faithful_lowering"] is True + assert revenue["public"] is True + assert doubled["dax"] == "'sales'[amount] * 2" + assert doubled["source_format"] == "Sidemantic" + assert doubled["source_file"] == "models.yml" + assert doubled["faithful_lowering"] is True + + +def test_semantic_layer_describe_models_marks_import_warning_status(tmp_path): + layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) + layer.graph.import_warnings = [ + { + "code": "dax_translation_fallback", + "context": "measure", + "name": "revenue", + "message": "simulated warning", + } + ] + + revenue = next(metric for metric in layer.describe_models()["models"][0]["metrics"] if metric["name"] == "revenue") + + assert revenue["unsupported"] is True + assert revenue["faithful_lowering"] is False + assert revenue["import_warnings"][0]["code"] == "dax_translation_fallback" diff --git a/tests/dax/test_query_corpus.py b/tests/dax/test_query_corpus.py new file mode 100644 index 00000000..2c29b45c --- /dev/null +++ b/tests/dax/test_query_corpus.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import sidemantic_dax + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE_PATH = ROOT / "tests" / "dax" / "fixtures" / "query-docs" / "queries.txt" + + +def _parse_query(text: str): + try: + return sidemantic_dax.parse_query(text) + except RuntimeError as exc: + if "native module is not available" in str(exc): + pytest.skip("sidemantic_dax native module not available") + raise + + +def _load_blocks(path: Path) -> list[tuple[str, str]]: + blocks: list[list[str]] = [] + current: list[str] = [] + for line in path.read_text().splitlines(): + if line.strip() == "---": + if current: + blocks.append(current) + current = [] + continue + current.append(line) + if current: + blocks.append(current) + + out: list[tuple[str, str]] = [] + for block in blocks: + source = "" + expr_lines: list[str] = [] + for line in block: + if line.startswith("# source:"): + source = line.replace("# source:", "", 1).strip() + continue + expr_lines.append(line) + query = "\n".join(expr_lines).strip() + if query: + out.append((source, query)) + return out + + +def _queries_with(keyword: str) -> list[tuple[str, str]]: + keyword_upper = keyword.upper() + pattern = rf"\b{re.escape(keyword_upper)}\b" + return [(source, query) for source, query in _load_blocks(FIXTURE_PATH) if re.search(pattern, query.upper())] + + +def test_parse_query_corpus_evaluate_examples(): + for source, query in _queries_with("EVALUATE"): + parsed = _parse_query(query) + assert parsed.evaluates, f"no evaluates parsed for {source}" + + +def test_parse_query_corpus_define_examples(): + for source, query in _queries_with("DEFINE"): + parsed = _parse_query(query) + assert parsed.define is not None, f"define block missing for {source}" + assert parsed.define.defs, f"no define defs parsed for {source}" + + +def test_parse_query_corpus_order_by_examples(): + for source, query in _queries_with("ORDER BY"): + parsed = _parse_query(query) + assert parsed.evaluates, f"no evaluates parsed for {source}" + assert any(stmt.order_by for stmt in parsed.evaluates), f"order by missing for {source}" + + +def test_parse_query_corpus_start_at_examples(): + for source, query in _queries_with("START AT"): + parsed = _parse_query(query) + assert parsed.evaluates, f"no evaluates parsed for {source}" + assert any(stmt.start_at for stmt in parsed.evaluates), f"start at missing for {source}" diff --git a/tests/dax/test_query_translation.py b/tests/dax/test_query_translation.py new file mode 100644 index 00000000..f90419b7 --- /dev/null +++ b/tests/dax/test_query_translation.py @@ -0,0 +1,7081 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import sidemantic_dax + +from sidemantic.dax import DaxTranslationError, RelationshipEdge, translate_dax_query +from sidemantic.dax.translator import _rewrite_expr_for_alias + +ROOT = Path(__file__).resolve().parents[2] + + +def _parse_query(query: str): + try: + return sidemantic_dax.parse_query(query) + except RuntimeError as exc: + if "native module is not available" in str(exc): + pytest.skip("sidemantic_dax native module not available") + raise + + +def _query_docs_blocks() -> list[str]: + fixture = ROOT / "tests" / "dax" / "fixtures" / "query-docs" / "queries.txt" + text = fixture.read_text() + blocks = [block.strip() for block in text.split("---") if block.strip()] + queries: list[str] = [] + for block in blocks: + lines = [line for line in block.splitlines() if not line.strip().startswith("# source:")] + query = "\n".join(lines).strip() + if query: + queries.append(query) + return queries + + +def test_translate_query_order_by_and_start_at(): + query = _parse_query( + """ + EVALUATE + 'Sales Order' + ORDER BY 'Sales Order'[Sales Order] ASC + START AT "SO43661" + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales Order": { + "Sales Order": '"Sales Order"', + } + }, + measure_names_by_table={"Sales Order": set()}, + ) + + assert len(translation.evaluates) == 1 + sql = translation.evaluates[0].sql + assert 'SELECT * FROM (SELECT * FROM "Sales Order") AS q' in sql + assert "WHERE (q.\"Sales Order\" >= 'SO43661')" in sql + assert 'ORDER BY q."Sales Order" ASC' in sql + + +def test_translate_query_metric_reference_preserves_metric_filters(): + query = _parse_query('EVALUATE ROW("West", [West Sales])') + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": { + "Amount": "Amount", + "Region": "Region", + } + }, + measure_names_by_table={"Sales": {"West Sales"}}, + measure_aggs_by_table={"Sales": {"West Sales": "sum"}}, + measure_sql_by_table={"Sales": {"West Sales": "Amount"}}, + measure_filters_by_table={"Sales": {"West Sales": ["Sales.Region = 'West'"]}}, + ) + + sql = translation.evaluates[0].sql + assert "SUM(CASE WHEN Sales.Region = 'West' THEN Sales.Amount ELSE NULL END)" in sql + + +def test_translate_query_docs_fixture_blocks(): + column_sql_by_table = { + "Sales Order": { + "Sales Order": '"Sales Order"', + "Sales Order Line": '"Sales Order Line"', + "SalesOrderLineKey": "SalesOrderLineKey", + "CustomerKey": "CustomerKey", + "DateKey": "DateKey", + }, + "Date": { + "Month Name": '"Month Name"', + "Month of Year": '"Month of Year"', + "Fiscal Year": '"Fiscal Year"', + "DateKey": "DateKey", + }, + "Product": { + "Category": "Category", + }, + "Sales": { + "Amount": "Amount", + "ProductKey": "ProductKey", + "DateKey": "DateKey", + "CustomerKey": "CustomerKey", + }, + "Customer": { + "CustomerKey": "CustomerKey", + }, + "Unbought products": { + "Year Range": '"Year Range"', + }, + "Pick a sales measure": {}, + } + + for idx, query_text in enumerate(_query_docs_blocks(), start=1): + query = _parse_query(query_text) + translation = translate_dax_query(query, column_sql_by_table=column_sql_by_table) + assert translation.evaluates, f"Expected EVALUATE statements for query-doc block {idx}" + + +def test_translate_query_keepfilters_wrapped_table_expression(): + query = _parse_query( + """ + EVALUATE + KEEPFILTERS( + FILTER('Sales', 'Sales'[Amount] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Amount > 0)" in sql + + +def test_translate_query_nonvisual_wrapped_table_expression(): + query = _parse_query( + """ + EVALUATE + NONVISUAL( + FILTER('Sales', 'Sales'[Amount] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Amount > 0)" in sql + + +def test_translate_query_order_by_multikey_start_at_mixed_direction(): + query = _parse_query( + """ + EVALUATE + 'Sales' + ORDER BY 'Sales'[Region] ASC, 'Sales'[Amount] DESC + START AT "US", 100 + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": { + "Region": "Region", + "Amount": "Amount", + } + }, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (q.Region > 'US') OR (q.Region = 'US' AND q.Amount <= 100)" in sql + assert "ORDER BY q.Region ASC, q.Amount DESC" in sql + + +def test_translate_query_order_by_multikey_start_at_prefix(): + query = _parse_query( + """ + EVALUATE + 'Sales' + ORDER BY 'Sales'[Region] ASC, 'Sales'[Amount] DESC + START AT "US" + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": { + "Region": "Region", + "Amount": "Amount", + } + }, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (q.Region >= 'US')" in sql + assert "ORDER BY q.Region ASC, q.Amount DESC" in sql + + +def test_translate_query_order_by_expression_start_at(): + query = _parse_query( + """ + EVALUATE + 'Sales' + ORDER BY UPPER('Sales'[Region]) ASC + START AT "US" + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": { + "Region": "Region", + } + }, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (UPPER(q.Region) >= 'US')" in sql + assert "ORDER BY UPPER(q.Region) ASC" in sql + + +def test_translate_query_order_by_expression_start_at_when_sqlglot_rewrite_fails(monkeypatch): + import sqlglot + + query = _parse_query( + """ + EVALUATE + 'Sales' + ORDER BY UPPER('Sales'[Region]) ASC + START AT "US" + """ + ) + + monkeypatch.setattr(sqlglot, "parse_one", lambda *args, **kwargs: (_ for _ in ()).throw(ValueError("boom"))) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": { + "Region": "Region", + } + }, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (UPPER(q.Region) >= 'US')" in sql + assert "ORDER BY UPPER(q.Region) ASC" in sql + + +def test_translate_query_start_at_accepts_expression_value(): + query = _parse_query( + """ + EVALUATE + 'Sales' + ORDER BY 'Sales'[Order Date] ASC + START AT DATE(2024, 1, 1) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": { + "Order Date": '"Order Date"', + } + }, + ) + + sql = translation.evaluates[0].sql + assert 'WHERE (q."Order Date" >= MAKE_DATE(2024, 1, 1))' in sql + assert 'ORDER BY q."Order Date" ASC' in sql + + +def test_translate_query_summarizecolumns_order_by(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Month of Year], + "Revenue", SUM('Sales'[Amount]) + ) + ORDER BY 'Date'[Month of Year] ASC + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey", "Month of Year": '"Month of Year"'}, + "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="DateKey", + to_table="Date", + to_column="DateKey", + ) + ], + ) + + assert len(translation.evaluates) == 1 + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert 'GROUP BY "Month of Year"' in sql + assert 'ORDER BY q."Month of Year" ASC' in sql + + +def test_translate_query_summarizecolumns_countrows_cross_table_uses_relationship_group_context(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Month of Year], + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey", "Month of Year": '"Month of Year"'}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="DateKey", + to_table="Date", + to_column="DateKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(Sales.DateKey) AS Rows" in sql + assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql + assert 'GROUP BY "Month of Year"' in sql + + +def test_translate_query_summarizecolumns_countrows_relatedtable_uses_relationship_group_context(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + "Rows", COUNTROWS(RELATEDTABLE('Sales')) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="DateKey", + to_table="Date", + to_column="DateKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(Sales.DateKey) AS Rows" in sql + assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_summarizecolumns_countrows_calculatetable_related_table_uses_relationship_group_context(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + "Customers", COUNTROWS(CALCULATETABLE('Customer')) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + "Sales": {"DateKey": "DateKey", "CustomerKey": "CustomerKey"}, + "Customer": {"CustomerKey": "CustomerKey"}, + }, + relationship_edges=[ + RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), + RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), + ], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(Customer.CustomerKey) AS Customers" in sql + assert ( + "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" + in sql + ) + assert "GROUP BY Date.FiscalYear" in sql + + +@pytest.mark.parametrize("wrapper", ["VALUES", "FILTERS", "DISTINCT"]) +def test_translate_query_summarizecolumns_countrows_distinct_table_wrappers_use_group_context(wrapper: str): + query = _parse_query( + f""" + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + "Customers", COUNTROWS({wrapper}('Customer')) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + "Sales": {"DateKey": "DateKey", "CustomerKey": "CustomerKey"}, + "Customer": {"Name": "Name", "CustomerKey": "CustomerKey"}, + }, + relationship_edges=[ + RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), + RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), + ], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(DISTINCT Customer.CustomerKey) AS Customers" in sql + assert ( + "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" + in sql + ) + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_summarizecolumns_countrows_calculatetable_values_with_filter_uses_grouped_distinct_count(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + "Customers", COUNTROWS(CALCULATETABLE(VALUES('Customer'), 'Customer'[Name] <> "")) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + "Sales": {"DateKey": "DateKey", "CustomerKey": "CustomerKey"}, + "Customer": {"Name": "Name", "CustomerKey": "CustomerKey"}, + }, + relationship_edges=[ + RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), + RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), + ], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(DISTINCT CASE WHEN (Customer.Name <> '') THEN Customer.CustomerKey END) AS Customers" in sql + assert ( + "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" + in sql + ) + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_summarizecolumns_keepfilters_filter(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + KEEPFILTERS(FILTER('Sales', 'Sales'[ProductKey] = 1)), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "WHERE (Sales.ProductKey = 1)" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarizecolumns_filter_with_keepfilters_wrapped_base_table(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + FILTER( + KEEPFILTERS(FILTER('Sales', 'Sales'[Amount] > 0)), + 'Sales'[Amount] > 10 + ), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (Sales.Amount > 0) AND (Sales.Amount > 10)" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarizecolumns_filter_with_nonvisual_wrapped_base_table(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + FILTER( + NONVISUAL(FILTER('Sales', 'Sales'[Amount] > 0)), + 'Sales'[Amount] > 10 + ), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (Sales.Amount > 0) AND (Sales.Amount > 10)" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarizecolumns_all_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + ALL('Sales'), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarizecolumns_allnoblankrow_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + ALLNOBLANKROW('Sales'), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarizecolumns_groupby_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + GROUPBY( + FILTER('Sales', 'Sales'[Amount] > 0), + 'Sales'[Category] + ), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "WHERE (Sales.Amount > 0)" in sql + assert "GROUP BY Sales.Category" in sql + + +def test_translate_query_summarizecolumns_datatable_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + DATATABLE( + "k", INTEGER, + "v", STRING, + {{1, "a"}} + ), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "GROUP BY Sales.Category" in sql + + +def test_translate_query_summarizecolumns_topnskip_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + TOPNSKIP( + 2, + 0, + FILTER('Sales', 'Sales'[Amount] > 0), + 'Sales'[Amount], DESC + ), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "WHERE (Sales.Amount > 0)" in sql + assert "GROUP BY Sales.Category" in sql + + +def test_translate_query_summarizecolumns_rejects_scalar_function_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + ABS(1), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZECOLUMNS argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_summarizecolumns_rejects_unknown_identifier_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + UnknownIdentifier, + "Rows", COUNTROWS('Sales') + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZECOLUMNS argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_summarizecolumns_rejects_unknown_table_function_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + UNKNOWNTABLEFN('Sales'), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZECOLUMNS argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_unknown_table_function_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + UNKNOWNTABLEFN('Sales') + """ + ) + + with pytest.raises(DaxTranslationError, match="Unsupported table function 'UNKNOWNTABLEFN'"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_unknown_scalar_function_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", UNKNOWNFUNC(1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="Unsupported scalar function 'UNKNOWNFUNC'"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +@pytest.mark.parametrize( + ("scalar_expr", "func_name"), + [ + ("SELECTEDMEASURE()", "SELECTEDMEASURE"), + ("SELECTEDMEASURENAME()", "SELECTEDMEASURENAME"), + ("SELECTEDMEASUREFORMATSTRING()", "SELECTEDMEASUREFORMATSTRING"), + ("ISSELECTEDMEASURE(1)", "ISSELECTEDMEASURE"), + ], +) +def test_translate_query_calc_group_scalar_function_error_is_explicit(scalar_expr: str, func_name: str): + query = _parse_query( + f""" + EVALUATE + ROW("x", {scalar_expr}) + """ + ) + + with pytest.raises(DaxTranslationError, match=f"{func_name} is only supported in calculation group expressions"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_detailrows_table_function_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + DETAILROWS('Sales') + """ + ) + + with pytest.raises(DaxTranslationError, match="DETAILROWS is only supported in model detail rows expressions"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_substitutewithindex_table_expression(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX('Sales', "Idx", VALUES('Sales'[Category]), 'Sales'[Category]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT l.Amount, i.__substitutewithindex_rank AS Idx FROM (SELECT * FROM Sales) AS l LEFT JOIN (" in sql + assert "DENSE_RANK() OVER (ORDER BY i0.Category ASC) AS __substitutewithindex_rank" in sql + assert "ON l.Category IS NOT DISTINCT FROM i.Category" in sql + + +def test_translate_query_substitutewithindex_table_expression_desc_order(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX('Sales', "Idx", VALUES('Sales'[Category]), 'Sales'[Category], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "DENSE_RANK() OVER (ORDER BY i0.Category DESC) AS __substitutewithindex_rank" in sql + + +def test_translate_query_substitutewithindex_table_expression_multi_order_keys(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + 'Sales', + "Idx", + SUMMARIZE('Sales', 'Sales'[Category], 'Sales'[Amount]), + 'Sales'[Category], ASC, + 'Sales'[Amount], DESC + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "DENSE_RANK() OVER (ORDER BY i0.Category ASC, i0.Amount DESC) AS __substitutewithindex_rank" in sql + + +def test_translate_query_substitutewithindex_requires_exactly_one_index_table_argument(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX('Sales', "Idx", 'Sales'[Category], 'Sales'[Category], ASC) + """ + ) + + with pytest.raises(DaxTranslationError, match="SUBSTITUTEWITHINDEX requires exactly one index table argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + +def test_translate_query_substitutewithindex_supports_wrapped_index_table_argument(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + 'Sales', + "Idx", + CALCULATETABLE( + VALUES('Sales'[Category]), + 'Sales'[Amount] > 100 + ), + 'Sales'[Category] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Sales.Category FROM Sales" in sql + assert "WHERE (Amount > 100)" in sql + assert "DENSE_RANK() OVER (ORDER BY i0.Category ASC) AS __substitutewithindex_rank" in sql + + +def test_translate_query_substitutewithindex_rejects_order_by_column_not_from_index_table(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + 'Sales', + "Idx", + VALUES('Sales'[Category]), + 'Sales'[Amount], + ASC + ) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument", + ): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + +def test_translate_query_substitutewithindex_supports_cross_table_order_by_column_from_index_table(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + VALUES('Sales'[Amount]), + "Idx", + CROSSJOIN('Sales', 'Products'), + 'Products'[Weight], + DESC + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "DENSE_RANK() OVER (ORDER BY i0.Weight DESC) AS __substitutewithindex_rank" in sql + + +def test_translate_query_substitutewithindex_rejects_ambiguous_common_column_in_source_table(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + CROSSJOIN('Sales', 'Products'), + "Idx", + VALUES('Sales'[ProductKey]), + 'Sales'[ProductKey] + ) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="SUBSTITUTEWITHINDEX source table has ambiguous common column 'ProductKey'", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, + "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, + }, + ) + + +def test_translate_query_substitutewithindex_rejects_ambiguous_order_by_column_in_index_table_qualified_ref(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + VALUES('Sales'[Amount]), + "Idx", + CROSSJOIN('Sales', 'Products'), + 'Products'[ProductKey] + ) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="SUBSTITUTEWITHINDEX ORDER BY column 'ProductKey' is ambiguous in index table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, + "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, + }, + ) + + +def test_translate_query_substitutewithindex_rejects_ambiguous_common_column_in_index_table(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + VALUES('Sales'[ProductKey]), + "Idx", + CROSSJOIN('Sales', 'Products'), + 'Sales'[ProductKey] + ) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="SUBSTITUTEWITHINDEX index table has ambiguous common column 'ProductKey'", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, + "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, + }, + ) + + +def test_translate_query_substitutewithindex_rejects_ambiguous_order_by_column_in_index_table(): + query = _parse_query( + """ + EVALUATE + SUBSTITUTEWITHINDEX( + VALUES('Sales'[Amount]), + "Idx", + CROSSJOIN('Sales', 'Products'), + 'Sales'[ProductKey] + ) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="SUBSTITUTEWITHINDEX ORDER BY column 'ProductKey' is ambiguous in index table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, + "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, + }, + ) + + +def test_translate_query_substitutewithindex_in_scalar_context_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", SUBSTITUTEWITHINDEX('Sales', "Idx", 'Sales'[Category], 'Sales')) + """ + ) + + with pytest.raises( + DaxTranslationError, match="SUBSTITUTEWITHINDEX returns a table and is not valid in scalar context" + ): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_evaluate_calculatetable_substitutewithindex_preserves_underlying_filters(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + 'Sales', + SUBSTITUTEWITHINDEX( + FILTER('Sales', 'Sales'[Amount] > 100), + "Idx", + VALUES('Sales'[Category]), + 'Sales'[Category] + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Category": "Category"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql + + +@pytest.mark.parametrize( + ("table_expr", "func_name"), + [ + ("SELECTEDMEASURE()", "SELECTEDMEASURE"), + ("SELECTEDMEASURENAME()", "SELECTEDMEASURENAME"), + ("SELECTEDMEASUREFORMATSTRING()", "SELECTEDMEASUREFORMATSTRING"), + ("ISSELECTEDMEASURE(1)", "ISSELECTEDMEASURE"), + ], +) +def test_translate_query_calc_group_scalar_function_in_table_context_error_is_explicit(table_expr: str, func_name: str): + query = _parse_query( + f""" + EVALUATE + {table_expr} + """ + ) + + with pytest.raises(DaxTranslationError, match=f"{func_name} is only supported in calculation group expressions"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_row_table_function_in_scalar_context_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", INTERSECT({1, 2}, {2, 3})) + """ + ) + + with pytest.raises(DaxTranslationError, match="INTERSECT returns a table and is not valid in scalar context"): + translate_dax_query(query) + + +def test_translate_query_row_keepcolumns_table_function_in_scalar_context_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", KEEPCOLUMNS('Sales', 'Sales'[ProductKey])) + """ + ) + + with pytest.raises(DaxTranslationError, match="KEEPCOLUMNS returns a table and is not valid in scalar context"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + +def test_translate_query_row_calculate_filter_only_function_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", USERELATIONSHIP('sales'[product_key], 'products'[product_key])) + """ + ) + + with pytest.raises(DaxTranslationError, match="USERELATIONSHIP is only valid in CALCULATE filter arguments"): + translate_dax_query(query) + + +def test_translate_query_row_crossfilter_calculate_filter_only_function_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)) + """ + ) + + with pytest.raises(DaxTranslationError, match="CROSSFILTER is only valid in CALCULATE filter arguments"): + translate_dax_query(query) + + +def test_translate_query_row_previousweek_table_function_in_scalar_context_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", PREVIOUSWEEK('Date'[DateKey])) + """ + ) + + with pytest.raises(DaxTranslationError, match="PREVIOUSWEEK returns a table and is not valid in scalar context"): + translate_dax_query( + query, + column_sql_by_table={"Date": {"DateKey": "DateKey"}}, + time_dimensions_by_table={"Date": {"DateKey"}}, + ) + + +def test_translate_query_row_nextweek_table_function_in_scalar_context_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", NEXTWEEK('Date'[DateKey])) + """ + ) + + with pytest.raises(DaxTranslationError, match="NEXTWEEK returns a table and is not valid in scalar context"): + translate_dax_query( + query, + column_sql_by_table={"Date": {"DateKey": "DateKey"}}, + time_dimensions_by_table={"Date": {"DateKey"}}, + ) + + +def test_translate_query_row_rollup_wrapper_in_scalar_context_error_is_explicit(): + query = _parse_query( + """ + EVALUATE + ROW("x", ROLLUP('Sales'[ProductKey])) + """ + ) + + with pytest.raises(DaxTranslationError, match="ROLLUP returns a table and is not valid in scalar context"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + +@pytest.mark.parametrize( + ("func_expr", "func_name"), + [ + ("ROLLUPGROUP('Sales'[ProductKey])", "ROLLUPGROUP"), + ("ROLLUPADDISSUBTOTAL(\"IsTotal\", 'Sales'[ProductKey])", "ROLLUPADDISSUBTOTAL"), + ("ROLLUPISSUBTOTAL(\"IsTotal\", 'Sales'[ProductKey])", "ROLLUPISSUBTOTAL"), + ], +) +def test_translate_query_row_rollup_wrapper_table_functions_in_scalar_context_error_is_explicit( + func_expr: str, func_name: str +): + query = _parse_query( + f""" + EVALUATE + ROW("x", {func_expr}) + """ + ) + + with pytest.raises(DaxTranslationError, match=rf"{func_name} returns a table and is not valid in scalar context"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + +def test_translate_query_sum_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", SUM(1, 2)) + """ + ) + + with pytest.raises(DaxTranslationError, match="SUM supports exactly one argument"): + translate_dax_query(query) + + +def test_translate_query_countrows_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", COUNTROWS('Sales', 'Sales')) + """ + ) + + with pytest.raises(DaxTranslationError, match="COUNTROWS supports at most one argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_divide_rejects_more_than_three_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", DIVIDE(10, 2, 0, 1)) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="DIVIDE supports at most numerator, denominator, and alternate result arguments", + ): + translate_dax_query(query) + + +def test_translate_query_and_or_reject_more_than_two_arguments(): + and_query = _parse_query( + """ + EVALUATE + ROW("x", AND(TRUE(), FALSE(), TRUE())) + """ + ) + or_query = _parse_query( + """ + EVALUATE + ROW("x", OR(TRUE(), FALSE(), TRUE())) + """ + ) + + with pytest.raises(DaxTranslationError, match="AND supports exactly two arguments"): + translate_dax_query(and_query) + + with pytest.raises(DaxTranslationError, match="OR supports exactly two arguments"): + translate_dax_query(or_query) + + +def test_translate_query_weekday_rejects_more_than_two_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", WEEKDAY("2024-02-01", 2, 3)) + """ + ) + + with pytest.raises(DaxTranslationError, match="WEEKDAY supports at most date and return_type arguments"): + translate_dax_query(query) + + +def test_translate_query_year_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", YEAR("2024-02-01", 2)) + """ + ) + + with pytest.raises(DaxTranslationError, match="YEAR supports exactly one argument"): + translate_dax_query(query) + + +def test_translate_query_left_rejects_more_than_two_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", LEFT("abcd", 2, 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="LEFT supports at most text and num_chars arguments"): + translate_dax_query(query) + + +def test_translate_query_date_ctor_rejects_more_than_three_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", DATE(2024, 2, 1, 3)) + """ + ) + + with pytest.raises(DaxTranslationError, match="DATE requires year, month, and day arguments"): + translate_dax_query(query) + + +def test_translate_query_value_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", VALUE("10", 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="VALUE supports exactly one argument"): + translate_dax_query(query) + + +def test_translate_query_concatenate_rejects_more_than_two_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", CONCATENATE("a", "b", "c")) + """ + ) + + with pytest.raises(DaxTranslationError, match="CONCATENATE supports exactly two arguments"): + translate_dax_query(query) + + +def test_translate_query_concatenatex_requires_table_and_expression_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", CONCATENATEX('Sales')) + """ + ) + + with pytest.raises(DaxTranslationError, match="CONCATENATEX requires table and expression arguments"): + translate_dax_query(query) + + +def test_translate_query_selectedvalue_rejects_more_than_two_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", SELECTEDVALUE('Sales'[Category], "a", "b")) + """ + ) + + with pytest.raises( + DaxTranslationError, match="SELECTEDVALUE supports at most column and alternate_result arguments" + ): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_hasonevalue_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", HASONEVALUE('Sales'[Category], 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="HASONEVALUE/HASONEFILTER supports exactly one argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_firstnonblank_rejects_more_than_two_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", FIRSTNONBLANK('Sales'[Category], 'Sales'[Category], 1)) + """ + ) + + with pytest.raises( + DaxTranslationError, match="FIRSTNONBLANK/LASTNONBLANK supports exactly column and expression arguments" + ): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_isfiltered_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", ISFILTERED('Sales'[Category], 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="ISFILTERED/ISCROSSFILTERED supports exactly one argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_nameof_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", NAMEOF('Sales'[Category], 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="NAMEOF supports exactly one argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + ) + + +def test_translate_query_today_rand_reject_arguments(): + today_query = _parse_query( + """ + EVALUATE + ROW("x", TODAY(1)) + """ + ) + rand_query = _parse_query( + """ + EVALUATE + ROW("x", RAND(1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="TODAY does not take arguments"): + translate_dax_query(today_query) + + with pytest.raises(DaxTranslationError, match="RAND does not take arguments"): + translate_dax_query(rand_query) + + +def test_translate_query_round_rejects_more_than_two_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", ROUND(1, 2, 3)) + """ + ) + + with pytest.raises(DaxTranslationError, match="ROUND requires number and num_digits arguments"): + translate_dax_query(query) + + +def test_translate_query_upper_isblank_reject_extra_arguments(): + upper_query = _parse_query( + """ + EVALUATE + ROW("x", UPPER("abc", "x")) + """ + ) + isblank_query = _parse_query( + """ + EVALUATE + ROW("x", ISBLANK(1, 2)) + """ + ) + + with pytest.raises(DaxTranslationError, match="UPPER supports exactly one argument"): + translate_dax_query(upper_query) + + with pytest.raises(DaxTranslationError, match="ISBLANK supports exactly one argument"): + translate_dax_query(isblank_query) + + +def test_translate_query_coalesce(): + query = _parse_query( + """ + EVALUATE + ROW("x", COALESCE('Sales'[Amount], 0)) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "COALESCE(Amount, 0) AS x" in sql + + +def test_translate_query_coalesce_requires_at_least_two_arguments(): + query = _parse_query( + """ + EVALUATE + ROW("x", COALESCE('Sales'[Amount])) + """ + ) + + with pytest.raises(DaxTranslationError, match="COALESCE requires at least two arguments"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_switch_boolean_predicate_form(): + query = _parse_query( + """ + EVALUATE + ROW("x", SWITCH(TRUE(), 'Sales'[Amount] > 0, 1, 0)) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN (Amount > 0) THEN 1 ELSE 0 END AS x" in sql + + +def test_translate_query_switch_requires_expression_and_value_result_pair(): + query = _parse_query( + """ + EVALUATE + ROW("x", SWITCH('Sales'[Amount])) + """ + ) + + with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + query_missing_result = _parse_query( + """ + EVALUATE + ROW("x", SWITCH('Sales'[Amount], 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): + translate_dax_query( + query_missing_result, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_isempty_table_expression(): + query = _parse_query( + """ + EVALUATE + ROW("x", ISEMPTY(FILTER('Sales', 'Sales'[Amount] > 0))) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "NOT EXISTS (SELECT 1 FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t) AS x" in sql + + +def test_translate_query_isempty_rejects_more_than_one_argument(): + query = _parse_query( + """ + EVALUATE + ROW("x", ISEMPTY('Sales', 'Sales')) + """ + ) + + with pytest.raises(DaxTranslationError, match="ISEMPTY supports exactly one argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_summarizecolumns_rollupgroup_group_by(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + ROLLUPGROUP('Sales'[ProductKey]), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarize_rejects_scalar_group_by_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZE( + 'Sales', + ABS(1), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZE group-by argument"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_summarizecolumns_rollupaddissubtotal_group_by(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + ROLLUPADDISSUBTOTAL('Sales'[ProductKey], "is_subtotal"), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarizecolumns_unrelated_tables_cross_join(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + 'Products'[Category] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey"}, + "Products": {"Category": "Category"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "FROM Sales CROSS JOIN Products" in sql + assert "GROUP BY Sales.ProductKey, Products.Category" in sql + + +def test_translate_query_summarizecolumns_cross_join_with_disconnected_component_relationship(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'A'[Id], + 'X'[XId], + 'Y'[YLabel] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "A": {"Id": "Id"}, + "X": {"XId": "XId", "YId": "YId"}, + "Y": {"YId": "YId", "YLabel": "YLabel"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="X", + from_column="YId", + to_table="Y", + to_column="YId", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "FROM A CROSS JOIN X LEFT JOIN Y ON X.YId = Y.YId" in sql + assert "GROUP BY A.Id, X.XId, Y.YLabel" in sql + + +def test_translate_query_summarizecolumns_treatas_filter(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + TREATAS({1, 2}, 'Sales'[ProductKey]), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "WHERE (Sales.ProductKey IN (1, 2))" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_evaluate_treatas_table_expression(): + query = _parse_query( + """ + EVALUATE + TREATAS({1, 2}, 'Sales'[ProductKey]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Sales.ProductKey AS ProductKey FROM Sales WHERE (Sales.ProductKey IN (1, 2))" in sql + + +def test_translate_query_evaluate_treatas_table_expression_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS(SELECTCOLUMNS('Sales', "k", 'Sales'[ProductKey], "q", 'Sales'[Quantity]), 'Sales'[ProductKey]) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity"}}, + ) + + +def test_translate_query_evaluate_treatas_filter_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS(FILTER('Sales', 'Sales'[Amount] > 10), 'Sales'[ProductKey]) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_treatas_values_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS( + FILTER(VALUES('Sales'[ProductKey]), 'Sales'[ProductKey] > 1), + 'Sales'[ProductKey], + 'Sales'[Quantity] + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity"}}, + ) + + +def test_translate_query_evaluate_treatas_union_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS(UNION('Sales', 'Sales'), 'Sales'[ProductKey]) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_treatas_naturalinnerjoin_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS(NATURALINNERJOIN('Sales', 'Sales'), 'Sales'[ProductKey]) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_treatas_naturalleftouterjoin_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS(NATURALLEFTOUTERJOIN('Sales', 'Sales'), 'Sales'[ProductKey]) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_treatas_renamecolumns_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS( + RENAMECOLUMNS('Sales', 'Sales'[ProductKey], "k"), + 'Sales'[ProductKey], + 'Sales'[Quantity] + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity", "Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_treatas_removecolumns_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS( + REMOVECOLUMNS('Sales', 'Sales'[Amount], 'Sales'[Quantity]), + 'Sales'[ProductKey], + 'Sales'[Quantity] + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity", "Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_treatas_keepcolumns_wrapper_rejects_width_mismatch(): + query = _parse_query( + """ + EVALUATE + TREATAS( + KEEPCOLUMNS('Sales', 'Sales'[ProductKey], 'Sales'[Quantity]), + 'Sales'[ProductKey] + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity", "Amount": "Amount"}}, + ) + + +def test_translate_query_summarizecolumns_datesbetween_filter(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + DATESBETWEEN('Sales'[OrderDate], "2024-01-01", "2024-12-31"), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "OrderDate": "OrderDate"}}, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (Sales.OrderDate >= '2024-01-01' AND Sales.OrderDate <= '2024-12-31')" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_evaluate_datesbetween_cross_table_start_bound_joins_referenced_table(): + query = _parse_query( + """ + EVALUATE + DATESBETWEEN('Date'[DateKey], 'Sales'[DateKey], "2024-12-31") + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey"}, + "Sales": {"DateKey": "DateKey"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="DateKey", + to_table="Date", + to_column="DateKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT Date.* FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql + assert "WHERE (Date.DateKey >= Sales.DateKey AND Date.DateKey <= '2024-12-31')" in sql + assert "Sales" in translation.evaluates[0].required_models + + +def test_translate_query_summarizecolumns_nonvisual_treatas_filter(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + NONVISUAL(TREATAS({1}, 'Sales'[ProductKey])), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (Sales.ProductKey IN (1))" in sql + assert "GROUP BY Sales.ProductKey" in sql + + +def test_translate_query_summarizecolumns_ignore_measure_expression(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + "Rows", IGNORE(COUNTROWS('Sales')) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "COUNT(*) AS Rows" in sql + assert "IGNORE(" not in sql + + +def test_translate_query_summarize_order_by(): + query = _parse_query( + """ + EVALUATE + SUMMARIZE( + 'Sales', + 'Sales'[Amount], + "Rows", COUNTROWS('Sales') + ) + ORDER BY 'Sales'[Amount] DESC + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + assert len(translation.evaluates) == 1 + sql = translation.evaluates[0].sql + assert "SELECT Sales.Amount, COUNT(*) AS Rows FROM Sales GROUP BY Sales.Amount" in sql + assert "ORDER BY q.Amount DESC" in sql + + +def test_translate_query_summarize_filter_table_expression_order_by(): + query = _parse_query( + """ + EVALUATE + SUMMARIZE( + FILTER('Sales', 'Sales'[Amount] > 10), + 'Sales'[Amount], + "Rows", COUNTROWS('Sales') + ) + ORDER BY 'Sales'[Amount] DESC + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + assert len(translation.evaluates) == 1 + sql = translation.evaluates[0].sql + assert "FROM (SELECT * FROM Sales WHERE (Amount > 10)) AS t" in sql + assert "SELECT Amount, COUNT(*) AS Rows" in sql + assert "GROUP BY Amount" in sql + assert "ORDER BY q.Amount DESC" in sql + + +def test_translate_query_summarize_wrapped_multitable_row_group_by_bracket_alias(): + query = _parse_query( + """ + EVALUATE + SUMMARIZE( + ROW("x", 'Sales'[Amount], "d", 'Date'[DateKey]), + [x] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT x FROM (SELECT Amount AS x, Date.DateKey AS d FROM Sales CROSS JOIN Date) AS t GROUP BY x" in sql + assert "Sales" in translation.evaluates[0].required_models + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_summarize_wrapped_multitable_row_group_by_identifier_alias(): + query = _parse_query( + """ + EVALUATE + SUMMARIZE( + ROW("x", 'Sales'[Amount], "d", 'Date'[DateKey]), + x + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT x FROM (SELECT Amount AS x, Date.DateKey AS d FROM Sales CROSS JOIN Date) AS t GROUP BY x" in sql + assert "Sales" in translation.evaluates[0].required_models + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_summarize_filter_table_expression_cross_table_group_by(): + query = _parse_query( + """ + EVALUATE + SUMMARIZE( + FILTER('Sales', 'Sales'[Amount] > 10), + 'Date'[Fiscal Year], + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "FROM (SELECT * FROM Sales WHERE (Amount > 10)) AS t LEFT JOIN Date ON t.DateKey = Date.DateKey" in sql + assert "SELECT Date.FiscalYear, COUNT(*) AS Rows" in sql + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_summarize_filter_table_expression_cross_join_disconnected_group_by(): + query = _parse_query( + """ + EVALUATE + SUMMARIZE( + FILTER('Sales', 'Sales'[Amount] > 10), + 'Product'[Category], + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Product": {"Category": "Category"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "FROM (SELECT * FROM Sales WHERE (Amount > 10)) AS t CROSS JOIN Product" in sql + assert "SELECT Product.Category, COUNT(*) AS Rows" in sql + assert "GROUP BY Product.Category" in sql + + +def test_translate_query_define_measure_inlines_bracket_reference(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales Order'[Orders] = DISTINCTCOUNT('Sales Order'[Sales Order]) + EVALUATE + SUMMARIZECOLUMNS( + 'Sales Order'[Sales Order], + "Orders", [Orders] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales Order": {"Sales Order": '"Sales Order"'}}, + measure_names_by_table={"Sales Order": {"Orders"}}, + ) + + assert len(translation.evaluates) == 1 + sql = translation.evaluates[0].sql + assert 'COUNT(DISTINCT "Sales Order") AS Orders' in sql + + +def test_translate_query_define_measure_with_identifier_table_ref(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[Customers] = COUNTROWS(Customer) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + "Customers", [Customers] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"SalesOrderLineKey": "SalesOrderLineKey"}, + "Date": {"FiscalYear": "FiscalYear"}, + "Customer": {"CustomerKey": "CustomerKey"}, + }, + measure_names_by_table={"Sales": {"Customers"}}, + ) + + assert len(translation.evaluates) == 1 + sql = translation.evaluates[0].sql + assert "(SELECT COUNT(*) FROM Customer) AS Customers" in sql + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_define_measure_with_identifier_table_ref_uses_relationship_group_context(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[Customers] = COUNTROWS(Customer) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + "Customers", [Customers] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"SalesOrderLineKey": "SalesOrderLineKey", "DateKey": "DateKey", "CustomerKey": "CustomerKey"}, + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + "Customer": {"Name": "Name", "CustomerKey": "CustomerKey"}, + }, + measure_names_by_table={"Sales": {"Customers"}}, + relationship_edges=[ + RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), + RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), + ], + ) + + assert len(translation.evaluates) == 1 + sql = translation.evaluates[0].sql + assert "COUNT(Customer.CustomerKey) AS Customers" in sql + assert ( + "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" + in sql + ) + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_define_measure_with_calculate_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Pick a sales measure'[Customers] = CALCULATE( + COUNTROWS(Customer), + FILTER( + 'Sales', + 'Sales'[Amount] > 0 + ) + ) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Customers", [Customers] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey", "CustomerKey": "CustomerKey"}, + "Customer": {"CustomerKey": "CustomerKey"}, + }, + measure_names_by_table={"Pick a sales measure": {"Customers"}}, + relationship_edges=[ + RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), + RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), + ], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(CASE WHEN" in sql + assert "Sales.Amount > 0" in sql + assert "AS Customers" in sql + + +def test_translate_query_define_measure_calculate_values_cross_table_filter_candidate(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[SalesByDateContext] = CALCULATE( + SUM('Sales'[Amount]), + VALUES('Date') + ) + EVALUATE + ROW("Value", [SalesByDateContext]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"SalesByDateContext"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + assert len(translation.evaluates) == 1 + assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_define_measure_calculate_table_ref_cross_table_filter_candidate(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[SalesByDateTableContext] = CALCULATE( + SUM('Sales'[Amount]), + 'Date' + ) + EVALUATE + ROW("Value", [SalesByDateTableContext]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"SalesByDateTableContext"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + assert len(translation.evaluates) == 1 + assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_define_measure_calculate_filters_cross_table_filter_candidate(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[SalesByDateFilterContext] = CALCULATE( + SUM('Sales'[Amount]), + FILTERS('Date'[DateKey]) + ) + EVALUATE + ROW("Value", [SalesByDateFilterContext]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"SalesByDateFilterContext"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + assert len(translation.evaluates) == 1 + assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_define_measure_calculate_sameperiodlastyear_cross_table_time_column(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[Prev] = CALCULATE( + SUM('Sales'[Amount]), + SAMEPERIODLASTYEAR('Date'[DateKey]) + ) + EVALUATE + ROW("Value", [Prev]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"Prev"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + time_dimensions_by_table={"Sales": {"DateKey"}, "Date": {"DateKey"}}, + ) + + assert len(translation.evaluates) == 1 + assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_define_measure_totalytd_cross_table_time_column_and_table_filter(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[YTD] = TOTALYTD( + SUM('Sales'[Amount]), + 'Date'[DateKey], + 'Date' + ) + EVALUATE + ROW("Value", [YTD]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"YTD"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + time_dimensions_by_table={"Sales": {"DateKey"}, "Date": {"DateKey"}}, + ) + + assert len(translation.evaluates) == 1 + assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_define_measure_with_countrows_datesbetween_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[RowsInRange] = COUNTROWS( + DATESBETWEEN('Sales'[OrderDate], "2024-01-01", "2024-12-31") + ) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "RowsInRange", [RowsInRange] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"OrderDate": "OrderDate", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"RowsInRange"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(CASE WHEN (Sales.OrderDate >= '2024-01-01' AND Sales.OrderDate <= '2024-12-31') THEN 1 END)" in sql + assert "AS RowsInRange" in sql + + +def test_translate_query_define_measure_with_countrows_filters_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[SelectedProducts] = COUNTROWS(FILTERS('Sales'[ProductKey])) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "SelectedProducts", [SelectedProducts] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"SelectedProducts"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(DISTINCT Sales.ProductKey) AS SelectedProducts" in sql + + +def test_translate_query_define_measure_with_countrows_cross_table_filters_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[SelectedDates] = COUNTROWS(FILTERS('Date'[DateKey])) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "SelectedDates", [SelectedDates] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"SelectedDates"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(DISTINCT Date.DateKey) AS SelectedDates" in sql + + +def test_translate_query_define_measure_with_countrows_all_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[AllRows] = COUNTROWS(ALL('Sales')) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "AllRows", [AllRows] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"AllRows"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "(SELECT COUNT(*) FROM (SELECT * FROM Sales) AS t) AS AllRows" in sql + + +def test_translate_query_define_measure_with_approximatedistinctcount_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[ApproxProducts] = APPROXIMATEDISTINCTCOUNT('Sales'[ProductKey]) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "ApproxProducts", [ApproxProducts] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"ApproxProducts"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(DISTINCT Sales.ProductKey) AS ApproxProducts" in sql + + +def test_translate_query_define_measure_with_sumx_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[PositiveAmount] = SUMX( + FILTER('Sales', 'Sales'[Amount] > 0), + 'Sales'[Amount] + ) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "PositiveAmount", [PositiveAmount] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"PositiveAmount"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SUM(CASE WHEN" in sql + assert "Sales.Amount > 0" in sql + assert "THEN Sales.Amount ELSE NULL END) AS PositiveAmount" in sql + + +def test_translate_query_define_measure_with_avgx_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[AvgPositiveAmount] = AVGX( + FILTER('Sales', 'Sales'[Amount] > 0), + 'Sales'[Amount] + ) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "AvgPositiveAmount", [AvgPositiveAmount] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"AvgPositiveAmount"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "AVG(CASE WHEN" in sql + assert "Sales.Amount > 0" in sql + assert "THEN Sales.Amount ELSE NULL END) AS AvgPositiveAmount" in sql + + +def test_translate_query_define_measure_with_countx_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[CountPositiveAmount] = COUNTX( + FILTER('Sales', 'Sales'[Amount] > 0), + 'Sales'[Amount] + ) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "CountPositiveAmount", [CountPositiveAmount] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"CountPositiveAmount"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(CASE WHEN" in sql + assert "Sales.Amount > 0" in sql + assert "THEN Sales.Amount END) AS CountPositiveAmount" in sql + + +def test_translate_query_define_measure_with_maxx_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[MaxPositiveAmount] = MAXX( + FILTER('Sales', 'Sales'[Amount] > 0), + 'Sales'[Amount] + ) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "MaxPositiveAmount", [MaxPositiveAmount] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"MaxPositiveAmount"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "MAX(CASE WHEN" in sql + assert "Sales.Amount > 0" in sql + assert "THEN Sales.Amount ELSE NULL END) AS MaxPositiveAmount" in sql + + +def test_translate_query_define_measure_with_countblank_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[BlankProductKeys] = COUNTBLANK('Sales'[ProductKey]) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "BlankProductKeys", [BlankProductKeys] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"BlankProductKeys"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "COUNT(CASE WHEN Sales.ProductKey IS NULL THEN 1 END) AS BlankProductKeys" in sql + + +def test_translate_query_define_measure_with_selectedvalue_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[SelectedProduct] = SELECTEDVALUE('Sales'[ProductKey], -1) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "SelectedProduct", [SelectedProduct] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"SelectedProduct"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert ( + "CASE WHEN COUNT(DISTINCT Sales.ProductKey) = 1 THEN MIN(Sales.ProductKey) ELSE -1 END AS SelectedProduct" + in sql + ) + + +def test_translate_query_define_measure_with_firstnonblank_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[FirstProductWithAmount] = FIRSTNONBLANK('Sales'[ProductKey], 'Sales'[Amount]) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "FirstProductWithAmount", [FirstProductWithAmount] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"FirstProductWithAmount"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert ( + "MIN(CASE WHEN Sales.Amount IS NOT NULL THEN Sales.ProductKey ELSE NULL END) AS FirstProductWithAmount" in sql + ) + + +def test_translate_query_define_measure_with_firstdate_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[FirstOrderDate] = FIRSTDATE('Sales'[OrderDate]) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "FirstOrderDate", [FirstOrderDate] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"OrderDate": "OrderDate", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"FirstOrderDate"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "MIN(Sales.OrderDate) AS FirstOrderDate" in sql + + +def test_translate_query_define_measure_with_isinscope_true_when_grouped(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[InScopeProduct] = ISINSCOPE('Sales'[ProductKey]) + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + "InScopeProduct", [InScopeProduct] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + measure_names_by_table={"Sales": {"InScopeProduct"}}, + ) + + sql = translation.evaluates[0].sql + assert "TRUE AS InScopeProduct" in sql + + +def test_translate_query_define_measure_with_isinscope_false_when_not_grouped(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[InScopeProduct] = ISINSCOPE('Sales'[ProductKey]) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "InScopeProduct", [InScopeProduct] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"InScopeProduct"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "FALSE AS InScopeProduct" in sql + + +def test_translate_query_define_measure_with_isfiltered_true_when_filtered(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[ProductFiltered] = ISFILTERED('Sales'[ProductKey]) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + TREATAS({1}, 'Sales'[ProductKey]), + "ProductFiltered", [ProductFiltered] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"ProductFiltered"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "TRUE AS ProductFiltered" in sql + + +def test_translate_query_selectcolumns_containsstring_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "HasOpen", + CONTAINSSTRING('Sales'[Status], "open") + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "(POSITION(LOWER('open') IN LOWER(Status)) > 0) AS HasOpen" in sql + + +def test_translate_query_selectcolumns_containsrow_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "HasKey", + CONTAINSROW(VALUES('Sales'[ProductKey]), 1) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert ( + "EXISTS (SELECT 1 FROM (SELECT DISTINCT Sales.ProductKey FROM Sales) AS t(c1) " + "WHERE t.c1 IS NOT DISTINCT FROM 1) AS HasKey" in sql + ) + + +def test_translate_query_row_containsrow_rejects_value_count_mismatch(): + query = _parse_query( + """ + EVALUATE + ROW( + "HasKey", + CONTAINSROW( + SELECTCOLUMNS('Sales', "k", 'Sales'[ProductKey], "q", 'Sales'[Quantity]), + 1 + ) + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="CONTAINSROW value argument count must match table column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity"}}, + ) + + +def test_translate_query_row_containsrow_rejects_non_inferable_table_width(): + query = _parse_query( + """ + EVALUATE + ROW( + "HasKey", + CONTAINSROW('Sales', 1) + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="CONTAINSROW requires an inferable table column count"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {}}, + ) + + +def test_translate_query_selectcolumns_len_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusLen", + LEN('Sales'[Status]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "LENGTH(Status) AS StatusLen" in sql + + +def test_translate_query_selectcolumns_left_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusLeft", + LEFT('Sales'[Status], 3) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "SUBSTRING(Status, 1, GREATEST(3, 0)) AS StatusLeft" in sql + + +def test_translate_query_selectcolumns_right_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusRight", + RIGHT('Sales'[Status], 3) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(Status, GREATEST(LENGTH(Status) - 3 + 1, 1), 3) END" in sql + assert "AS StatusRight" in sql + + +def test_translate_query_selectcolumns_mid_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusMid", + MID('Sales'[Status], 2, 3) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(Status, GREATEST(2, 1), 3) END AS StatusMid" in sql + + +def test_translate_query_selectcolumns_replace_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusReplaced", + REPLACE('Sales'[Status], 2, 2, "xx") + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN GREATEST(2, 1) <= 1 THEN '' ELSE SUBSTRING(Status, 1, GREATEST(2, 1) - 1) END" in sql + assert "|| 'xx' || SUBSTRING(Status, GREATEST(2, 1) + GREATEST(2, 0))" in sql + assert "AS StatusReplaced" in sql + + +def test_translate_query_selectcolumns_substitute_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusSubstituted", + SUBSTITUTE('Sales'[Status], "ab", "xy") + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "REPLACE(Status, 'ab', 'xy') AS StatusSubstituted" in sql + + +def test_translate_query_selectcolumns_substitute_instance_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusSubstituted", + SUBSTITUTE('Sales'[Status], "ab", "xy", 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN 'ab' = '' THEN Status" in sql + assert "INSTR(SUBSTR(Status, (INSTR(Status, 'ab')) + LENGTH('ab')), 'ab')" in sql + assert "AS StatusSubstituted" in sql + + +def test_translate_query_selectcolumns_rept_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusRepeated", + REPT('Sales'[Status], 3) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "REPEAT(Status, GREATEST(CAST(FLOOR(3) AS BIGINT), 0)) AS StatusRepeated" in sql + + +def test_translate_query_selectcolumns_trim_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "StatusTrimmed", + TRIM('Sales'[Status]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Status": "Status"}}, + ) + + sql = translation.evaluates[0].sql + assert "TRIM(REGEXP_REPLACE(CAST(Status AS VARCHAR), ' +', ' ', 'g')) AS StatusTrimmed" in sql + + +def test_translate_query_selectcolumns_weekday_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "WeekdayNum", + WEEKDAY('Sales'[OrderDate], 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"OrderDate": "OrderDate"}}, + ) + + sql = translation.evaluates[0].sql + assert "(((EXTRACT(DOW FROM CAST(OrderDate AS DATE)) + 6) % 7) + 1) AS WeekdayNum" in sql + + +def test_translate_query_selectcolumns_format_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountText", + FORMAT('Sales'[Amount], "0.00") + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "CAST(Amount AS VARCHAR) AS AmountText" in sql + + +def test_translate_query_selectcolumns_cross_table_expression_joins_related_table(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "Category", + 'Product'[Category] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert ( + "SELECT Product.Category AS Category FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" + in sql + ) + + +def test_translate_query_addcolumns_cross_table_expression_joins_related_table(): + query = _parse_query( + """ + EVALUATE + ADDCOLUMNS( + 'Sales', + "Category", + 'Product'[Category] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert ( + "SELECT Sales.*, Product.Category AS Category FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" + in sql + ) + + +def test_translate_query_selectcolumns_wrapped_base_cross_table_expression_joins_related_table(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + FILTER('Sales', 'Sales'[Amount] > 0), + "Category", + 'Product'[Category] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert ( + "SELECT Product.Category AS Category FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" + in sql + ) + + +def test_translate_query_addcolumns_wrapped_base_cross_table_expression_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + ADDCOLUMNS( + FILTER('Sales', 'Sales'[Amount] > 0), + "Rate", + 'Tax'[Rate] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT t.*, Tax.Rate AS Rate FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t CROSS JOIN Tax" in sql + + +def test_translate_query_selectcolumns_rounddown_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountRoundedDown", + ROUNDDOWN('Sales'[Amount], 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN 2 >= 0 THEN SIGN(Amount) * FLOOR(ABS(Amount) * POWER(10, 2)) / POWER(10, 2) ELSE" in sql + assert "AS AmountRoundedDown" in sql + + +def test_translate_query_selectcolumns_round_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountRounded", + ROUND('Sales'[Amount], 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN 2 >= 0 THEN SIGN(Amount) * FLOOR(ABS(Amount) * POWER(10, 2) + 0.5) / POWER(10, 2) ELSE" in sql + assert "AS AmountRounded" in sql + + +def test_translate_query_selectcolumns_int_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountInt", + INT('Sales'[Amount]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "FLOOR(Amount) AS AmountInt" in sql + + +def test_translate_query_selectcolumns_trunc_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountTrunc", + TRUNC('Sales'[Amount], 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN 2 >= 0 THEN SIGN(Amount) * FLOOR(ABS(Amount) * POWER(10, 2)) / POWER(10, 2) ELSE" in sql + assert "AS AmountTrunc" in sql + + +def test_translate_query_selectcolumns_ceiling_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountCeiling", + CEILING('Sales'[Amount], 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "(CEIL(Amount / 2) * 2) AS AmountCeiling" in sql + + +def test_translate_query_selectcolumns_floor_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountFloor", + FLOOR('Sales'[Amount], 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "(FLOOR(Amount / 2) * 2) AS AmountFloor" in sql + + +def test_translate_query_selectcolumns_mround_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountMround", + MROUND('Sales'[Amount], 2) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "CASE WHEN 2 = 0 THEN 0 ELSE SIGN(Amount) * FLOOR((ABS(Amount) / ABS(2)) + 0.5) * ABS(2) END" in sql + assert "AS AmountMround" in sql + + +def test_translate_query_evaluate_generateseries(): + query = _parse_query( + """ + EVALUATE + GENERATESERIES(1, 5, 2) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "SELECT value FROM generate_series(1, 5, 2) AS gs(value)" in sql + + +def test_translate_query_evaluate_calendar_cross_table_aggregate_bounds(): + query = _parse_query( + """ + EVALUATE + CALENDAR(MIN('Sales'[DateKey]), MAX('Date'[DateKey])) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "CAST((SELECT MIN(Sales.DateKey) FROM Sales) AS DATE)" in sql + assert "CAST((SELECT MAX(Date.DateKey) FROM Date) AS DATE)" in sql + assert "Sales" in translation.evaluates[0].required_models + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_selectcolumns_evaluateandlog_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "Amt", + EVALUATEANDLOG('Sales'[Amount]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "Amount AS Amt" in sql + + +def test_translate_query_selectcolumns_lookupvalue_expression(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "Category", + LOOKUPVALUE('Product'[Category], 'Product'[ProductKey], 'Sales'[ProductKey], "unknown") + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey"}, + "Product": {"Category": "Category", "ProductKey": "ProductKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Product.Category FROM Product WHERE Product.ProductKey = Sales.ProductKey LIMIT 1" in sql + assert "AS Category" in sql + + +def test_translate_query_summarizecolumns_related_expression(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + "Category", RELATED('Product'[Category]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey"}, + "Product": {"Category": "Category", "ProductKey": "ProductKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "ProductKey", "Product", "ProductKey")], + ) + + sql = translation.evaluates[0].sql + assert "Product.Category AS Category" in sql + assert "JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql + + +def test_translate_query_evaluate_relatedtable_expression(): + query = _parse_query( + """ + EVALUATE + RELATEDTABLE('Sales') + """ + ) + + translation = translate_dax_query(query, column_sql_by_table={"Sales": {"Amount": "Amount"}}) + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales" in sql + + +def test_translate_query_selectcolumns_find_and_search_expressions(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "SearchPos", SEARCH("ab", 'Sales'[Sku], 1, -1), + "FindPos", FIND("AB", 'Sales'[Sku], 1, 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Sku": "Sku"}}, + ) + sql = translation.evaluates[0].sql + assert "POSITION(LOWER('ab') IN SUBSTRING(LOWER(Sku), 1))" in sql + assert "POSITION('AB' IN SUBSTRING(Sku, 1))" in sql + assert "AS SearchPos" in sql + assert "AS FindPos" in sql + + +def test_translate_query_selectcolumns_now_and_datepart_expressions(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "Y", YEAR('Sales'[OrderDate]), + "Q", QUARTER('Sales'[OrderDate]), + "NowTs", NOW(), + "UtcDate", UTCTODAY() + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"OrderDate": "OrderDate"}}, + ) + sql = translation.evaluates[0].sql + assert "EXTRACT(YEAR FROM CAST(OrderDate AS DATE)) AS Y" in sql + assert "EXTRACT(QUARTER FROM CAST(OrderDate AS DATE)) AS Q" in sql + assert "CURRENT_TIMESTAMP AS NowTs" in sql + assert "CURRENT_DATE AS UtcDate" in sql + + +def test_translate_query_selectcolumns_value_and_concatenate_expressions(): + query = _parse_query( + """ + EVALUATE + SELECTCOLUMNS( + 'Sales', + "AmountNum", VALUE('Sales'[AmountText]), + "SkuTag", CONCATENATE('Sales'[Sku], "-X") + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"AmountText": "AmountText", "Sku": "Sku"}}, + ) + sql = translation.evaluates[0].sql + assert "CAST(AmountText AS DOUBLE) AS AmountNum" in sql + assert "(Sku || '-X') AS SkuTag" in sql + + +def test_translate_query_row_concatenatex_expression(): + query = _parse_query( + """ + EVALUATE + ROW( + "SkuList", + CONCATENATEX('Sales', 'Sales'[Sku], ",", 'Sales'[Amount], DESC) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Sku": "Sku", "Amount": "Amount"}}, + ) + sql = translation.evaluates[0].sql + assert "STRING_AGG(CAST(Sku AS VARCHAR), CAST(',' AS VARCHAR) ORDER BY Amount DESC)" in sql + assert "FROM Sales" in sql + assert "AS SkuList" in sql + + +def test_translate_query_summarizecolumns_median_and_medianx_expressions(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + "MedianAmt", MEDIAN('Sales'[Amount]), + "MedianAmtX", MEDIANX('Sales', 'Sales'[Amount]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, + ) + sql = translation.evaluates[0].sql + assert "MEDIAN(Sales.Amount) AS MedianAmt" in sql + assert "MEDIAN(Sales.Amount) AS MedianAmtX" in sql + + +def test_translate_query_define_measure_with_totalytd_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[SalesYTD] = TOTALYTD(SUM('Sales'[Amount]), 'Date'[Date]) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "SalesYTD", [SalesYTD] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "Date": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"SalesYTD"}}, + time_dimensions_by_table={"Date": {"Date"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + sql = translation.evaluates[0].sql + assert "OVER (" in sql + assert "AS SalesYTD" in sql + + +def test_translate_query_define_measure_with_sameperiodlastyear_expression(): + query = _parse_query( + """ + DEFINE + MEASURE 'Sales'[PrevSales] = CALCULATE(SUM('Sales'[Amount]), SAMEPERIODLASTYEAR('Date'[Date])) + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "PrevSales", [PrevSales] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "Date": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"PrevSales"}}, + time_dimensions_by_table={"Date": {"Date"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + sql = translation.evaluates[0].sql + assert "LAG(" in sql + assert "AS PrevSales" in sql + + +def test_translate_query_sameperiodlastyear_model_measure_reference_uses_measure_sql(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "PrevSales", + CALCULATE([Total Sales], SAMEPERIODLASTYEAR('Date'[Date])) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "Date": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"Total Sales"}}, + measure_aggs_by_table={"Sales": {"Total Sales": "sum"}}, + measure_sql_by_table={"Sales": {"Total Sales": "Amount"}}, + time_dimensions_by_table={"Date": {"Date"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + sql = translation.evaluates[0].sql + assert "LAG(SUM(Amount), 1) OVER (" in sql + assert "AS PrevSales" in sql + + +def test_translate_query_model_measure_reference_uses_measure_metadata(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + "Revenue", [Revenue] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + measure_names_by_table={"Sales": {"Revenue"}}, + measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, + measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "GROUP BY Sales.Category" in sql + + +def test_translate_query_model_measure_reference_joins_cross_table_grouping(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", [Revenue] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + measure_names_by_table={"Sales": {"Revenue"}}, + measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, + measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_calculate_model_measure_reference_applies_filters_inside_aggregate(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Category], + "Revenue", CALCULATE([Revenue], 'Sales'[Amount] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + measure_names_by_table={"Sales": {"Revenue"}}, + measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, + measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SUM(CASE WHEN (Sales.Amount > 0) THEN Sales.Amount ELSE NULL END) AS Revenue" in sql + assert "CASE WHEN (Sales.Amount > 0) THEN SUM" not in sql + + +def test_translate_query_row_model_measure_reference_adds_measure_table_source(): + query = _parse_query('EVALUATE ROW("Revenue", [Revenue])') + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category"}}, + measure_names_by_table={"Sales": {"Revenue"}}, + measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, + measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, + ) + + assert translation.evaluates[0].sql == "SELECT SUM(Sales.Amount) AS Revenue FROM Sales" + + +def test_translate_query_define_var_is_resolved(): + query = _parse_query( + """ + DEFINE + VAR threshold = 10 + EVALUATE + FILTER('Sales', 'Sales'[Amount] > threshold) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (Amount > 10)" in sql + + +def test_translate_query_define_function_is_inlined(): + query = _parse_query( + """ + DEFINE + FUNCTION add_tax = (x : NUMERIC) => x * 1.1 + EVALUATE + SELECTCOLUMNS( + 'Sales', + "Gross", + add_tax('Sales'[Amount]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "(Amount * 1.1) AS Gross" in sql + + +def test_translate_query_define_column_is_resolved(): + query = _parse_query( + """ + DEFINE + COLUMN 'Sales'[Net] = 'Sales'[Amount] - 1 + EVALUATE + SELECTCOLUMNS( + 'Sales', + "Net", + 'Sales'[Net] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Net": "Net"}}, + ) + + sql = translation.evaluates[0].sql + assert "(Amount - 1) AS Net" in sql + + +def test_translate_query_table_constructor_evaluate(): + query = _parse_query( + """ + EVALUATE + { 1, 2 } + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "SELECT 1 AS value1" in sql + assert "SELECT 2 AS value1" in sql + + +def test_translate_query_table_constructor_evaluate_with_table_column_ref_adds_from_clause(): + query = _parse_query( + """ + EVALUATE + { ('Sales'[Amount], 2) } + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Amount AS value1, 2 AS value2 FROM Sales" in sql + assert "Sales" in translation.evaluates[0].required_models + + +def test_translate_query_table_constructor_evaluate_allows_multi_table_refs(): + query = _parse_query( + """ + EVALUATE + { ('Sales'[Amount], 'Date'[DateKey]) } + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Amount AS value1, Date.DateKey AS value2 FROM Sales CROSS JOIN Date" in sql + assert "Sales" in translation.evaluates[0].required_models + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_row_evaluate(): + query = _parse_query( + """ + EVALUATE + ROW("one", 1, "two", 2) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "SELECT 1 AS one, 2 AS two" in sql + + +def test_translate_query_row_evaluate_allows_multi_table_refs(): + query = _parse_query( + """ + EVALUATE + ROW("sales_amount", 'Sales'[Amount], "date_key", 'Date'[DateKey]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Amount AS sales_amount, Date.DateKey AS date_key FROM Sales CROSS JOIN Date" in sql + assert "Sales" in translation.evaluates[0].required_models + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_row_evaluate_numeric_scalar_functions(): + query = _parse_query( + """ + EVALUATE + ROW( + "abs_value", ABS(-5), + "mod_value", MOD(10, 3), + "pow_value", POWER(2, 3), + "sqrt_value", SQRT(9), + "log_value", LOG(100), + "pi_value", PI(), + "min_value", MIN(2, 3), + "max_value", MAX(2, 3) + ) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "ABS(-5) AS abs_value" in sql + assert "MOD(10, 3) AS mod_value" in sql + assert "POWER(2, 3) AS pow_value" in sql + assert "SQRT(9) AS sqrt_value" in sql + assert "LOG10(100) AS log_value" in sql + assert "PI() AS pi_value" in sql + assert "LEAST(2, 3) AS min_value" in sql + assert "GREATEST(2, 3) AS max_value" in sql + + +def test_translate_query_row_requires_name_expression_pairs(): + query = _parse_query( + """ + EVALUATE + ROW("one", 1, "bad") + """ + ) + + with pytest.raises(DaxTranslationError, match="ROW requires name/expression pairs"): + translate_dax_query(query) + + +def test_translate_query_union_evaluate(): + query = _parse_query( + """ + EVALUATE + UNION({1}, {2}) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "UNION ALL" in sql + assert "SELECT * FROM (SELECT 1 AS value1) AS t0" in sql + assert "SELECT * FROM (SELECT 2 AS value1) AS t1" in sql + + +def test_translate_query_union_allows_multi_table_refs(): + query = _parse_query( + """ + EVALUATE + UNION('sales', 'tax') + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "tax": {"rate": "rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "UNION ALL" in sql + assert "SELECT * FROM (SELECT * FROM sales) AS t0" in sql + assert "SELECT * FROM (SELECT * FROM tax) AS t1" in sql + + +def test_translate_query_union_requires_two_tables(): + query = _parse_query( + """ + EVALUATE + UNION({1}) + """ + ) + + with pytest.raises(DaxTranslationError, match="UNION requires at least two table arguments"): + translate_dax_query(query) + + +def test_translate_query_intersect_evaluate(): + query = _parse_query( + """ + EVALUATE + INTERSECT({1, 2}, {2, 3}) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "INTERSECT ALL" in sql + assert "SELECT * FROM (SELECT 1 AS value1 UNION ALL SELECT 2 AS value1) AS t0" in sql + assert "SELECT * FROM (SELECT 2 AS value1 UNION ALL SELECT 3 AS value1) AS t1" in sql + + +def test_translate_query_except_evaluate(): + query = _parse_query( + """ + EVALUATE + EXCEPT({1, 2}, {2, 3}) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "EXCEPT ALL" in sql + assert "SELECT * FROM (SELECT 1 AS value1 UNION ALL SELECT 2 AS value1) AS t0" in sql + assert "SELECT * FROM (SELECT 2 AS value1 UNION ALL SELECT 3 AS value1) AS t1" in sql + + +def test_translate_query_intersect_requires_two_tables(): + query = _parse_query( + """ + EVALUATE + INTERSECT({1}) + """ + ) + + with pytest.raises(DaxTranslationError, match="INTERSECT requires exactly two table arguments"): + translate_dax_query(query) + + +def test_translate_query_except_requires_two_tables(): + query = _parse_query( + """ + EVALUATE + EXCEPT({1}) + """ + ) + + with pytest.raises(DaxTranslationError, match="EXCEPT requires exactly two table arguments"): + translate_dax_query(query) + + +def test_translate_query_crossjoin_evaluate(): + query = _parse_query( + """ + EVALUATE + CROSSJOIN({1}, {2}) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM (SELECT 1 AS value1) AS t0 CROSS JOIN (SELECT 2 AS value1) AS t1" in sql + + +def test_translate_query_crossjoin_requires_two_tables(): + query = _parse_query( + """ + EVALUATE + CROSSJOIN({1}) + """ + ) + + with pytest.raises(DaxTranslationError, match="CROSSJOIN requires at least two table arguments"): + translate_dax_query(query) + + +def test_translate_query_naturalinnerjoin_evaluate(): + query = _parse_query( + """ + EVALUATE + NATURALINNERJOIN(ROW("k", 1), ROW("k", 1)) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "NATURAL INNER JOIN" in sql + assert "SELECT * FROM (SELECT 1 AS k) AS t0 NATURAL INNER JOIN (SELECT 1 AS k) AS t1" in sql + + +def test_translate_query_naturalinnerjoin_requires_two_tables(): + query = _parse_query( + """ + EVALUATE + NATURALINNERJOIN(ROW("k", 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="NATURALINNERJOIN requires exactly two table arguments"): + translate_dax_query(query) + + +def test_translate_query_naturalleftouterjoin_evaluate(): + query = _parse_query( + """ + EVALUATE + NATURALLEFTOUTERJOIN(ROW("k", 1), ROW("k", 2)) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "NATURAL LEFT JOIN" in sql + assert "SELECT * FROM (SELECT 1 AS k) AS t0 NATURAL LEFT JOIN (SELECT 2 AS k) AS t1" in sql + + +def test_translate_query_naturalleftouterjoin_requires_two_tables(): + query = _parse_query( + """ + EVALUATE + NATURALLEFTOUTERJOIN(ROW("k", 1)) + """ + ) + + with pytest.raises(DaxTranslationError, match="NATURALLEFTOUTERJOIN requires exactly two table arguments"): + translate_dax_query(query) + + +def test_translate_query_generate_table(): + query = _parse_query( + """ + EVALUATE + GENERATE( + VALUES('Date'[Fiscal Year]), + VALUES('Product'[Category]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Product": {"Category": "Category"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "SELECT DISTINCT Date.FiscalYear FROM Date" in sql + assert "SELECT DISTINCT Product.Category FROM Product" in sql + + +def test_translate_query_generate_allows_cross_table_right_input(): + query = _parse_query( + """ + EVALUATE + GENERATE( + 'sales', + FILTER('tax', 'tax'[rate] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "tax": {"rate": "rate", "date_key": "date_key"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "FROM (SELECT * FROM sales) AS l" in sql + assert "FROM tax" in sql + assert "rate > 0" in sql + + +def test_translate_query_generateall_table(): + query = _parse_query( + """ + EVALUATE + GENERATEALL( + VALUES('Date'[Fiscal Year]), + VALUES('Product'[Category]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Product": {"Category": "Category"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "LEFT JOIN LATERAL" in sql + assert "ON TRUE" in sql + + +def test_translate_query_generateall_allows_cross_table_right_input(): + query = _parse_query( + """ + EVALUATE + GENERATEALL( + 'sales', + FILTER('tax', 'tax'[rate] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "tax": {"rate": "rate", "date_key": "date_key"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "LEFT JOIN LATERAL" in sql + assert "ON TRUE" in sql + assert "FROM (SELECT * FROM sales) AS l" in sql + assert "FROM tax" in sql + assert "rate > 0" in sql + + +def test_translate_query_generate_uses_lateral_join_shape(): + query = _parse_query( + """ + EVALUATE + GENERATE( + VALUES('Date'[Fiscal Year]), + FILTER('Date', 'Date'[Fiscal Year] > 2022) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "WHERE (FiscalYear > 2022)" in sql + + +def test_translate_query_generate_correlates_left_alias_column_in_right_filter(): + query = _parse_query( + """ + EVALUATE + GENERATE( + SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), + FILTER('Date', [FY] >= 2024) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "SELECT FiscalYear AS FY FROM Date" in sql + assert "WHERE (l.FY >= 2024)" in sql + + +def test_translate_query_generate_correlates_qualified_left_column_lineage_to_alias(): + query = _parse_query( + """ + EVALUATE + GENERATE( + SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), + FILTER('Date', 'Date'[Fiscal Year] >= 2024) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "SELECT FiscalYear AS FY FROM Date" in sql + assert "WHERE (FiscalYear >= 2024)" in sql + + +def test_translate_query_generate_does_not_correlate_non_projected_left_column(): + query = _parse_query( + """ + EVALUATE + GENERATE( + VALUES('Date'[Fiscal Year]), + VALUES('Date'[DateKey]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "SELECT DISTINCT Date.FiscalYear FROM Date" in sql + assert "SELECT DISTINCT Date.DateKey FROM Date" in sql + assert "l.DateKey" not in sql + + +def test_translate_query_generate_nested_filter_preserves_local_right_row_context(): + query = _parse_query( + """ + EVALUATE + GENERATE( + SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), + SUMMARIZE( + FILTER('Date', 'Date'[Fiscal Year] > [FY]), + 'Date'[DateKey] + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "WHERE (FiscalYear > l.FY)" in sql + assert "l.FY > l.FY" not in sql + + +def test_translate_query_generate_nested_filter_with_addcolumns_alias_keeps_right_row_context(): + query = _parse_query( + """ + EVALUATE + GENERATE( + ADDCOLUMNS(VALUES('Date'[Fiscal Year]), "FY2", 'Date'[Fiscal Year]), + FILTER('Date', 'Date'[Fiscal Year] = [FY2]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "WHERE (FiscalYear = l.FY2)" in sql + assert "l.FY2 = l.FY2" not in sql + + +def test_translate_query_generate_does_not_correlate_local_wrapped_alias_in_right_filter(): + query = _parse_query( + """ + EVALUATE + GENERATE( + SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), + FILTER( + SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), + [FY] >= 2024 + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "WHERE (FY >= 2024)" in sql + assert "WHERE (l.FY >= 2024)" not in sql + + +def test_translate_query_generate_correlates_outer_column_when_left_uses_star_projection(): + query = _parse_query( + """ + EVALUATE + GENERATE( + 'Date', + ROW("OuterDateKey", [DateKey]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"DateKey": "DateKey", "Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "SELECT l.DateKey AS OuterDateKey" in sql + + +def test_translate_query_generate_left_star_does_not_rewrite_local_wrapped_right_column(): + query = _parse_query( + """ + EVALUATE + GENERATE( + 'Date', + FILTER( + SELECTCOLUMNS('Date', "DateKey", 'Date'[DateKey]), + [DateKey] >= 2024 + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"DateKey": "DateKey", "Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "CROSS JOIN LATERAL" in sql + assert "WHERE (DateKey >= 2024)" in sql + assert "WHERE (l.DateKey >= 2024)" not in sql + + +def test_translate_query_generateall_correlates_outer_column_when_left_uses_star_projection(): + query = _parse_query( + """ + EVALUATE + GENERATEALL( + 'Date', + ROW("OuterDateKey", [DateKey]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"DateKey": "DateKey", "Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert "LEFT JOIN LATERAL" in sql + assert "ON TRUE" in sql + assert "SELECT l.DateKey AS OuterDateKey" in sql + + +def test_translate_query_generate_raises_for_ambiguous_multitable_star_outer_column_reference(): + query = _parse_query( + """ + EVALUATE + GENERATE( + CROSSJOIN( + VALUES('Date'[DateKey]), + VALUES('Sales'[DateKey]) + ), + ROW("OuterDateKey", [DateKey]) + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="Ambiguous outer column reference"): + translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey"}, + "Sales": {"DateKey": "DateKey"}, + }, + ) + + +def test_translate_query_generateall_raises_for_ambiguous_multitable_star_outer_column_reference(): + query = _parse_query( + """ + EVALUATE + GENERATEALL( + CROSSJOIN( + VALUES('Date'[DateKey]), + VALUES('Sales'[DateKey]) + ), + ROW("OuterDateKey", [DateKey]) + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="Ambiguous outer column reference"): + translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey"}, + "Sales": {"DateKey": "DateKey"}, + }, + ) + + +@pytest.mark.parametrize("fn_name", ["GENERATE", "GENERATEALL"]) +def test_translate_query_generate_raises_for_ambiguous_duplicate_lineage_outer_column_reference(fn_name: str): + query = _parse_query( + f""" + EVALUATE + {fn_name}( + SELECTCOLUMNS( + 'Date', + "DateKey1", 'Date'[DateKey], + "DateKey2", 'Date'[DateKey] + ), + ROW("OuterDateKey", 'Date'[DateKey]) + ) + """ + ) + + with pytest.raises(DaxTranslationError, match="Ambiguous outer column reference"): + translate_dax_query( + query, + column_sql_by_table={ + "Date": {"DateKey": "DateKey"}, + }, + ) + + +def test_rewrite_expr_for_alias_keeps_nested_qualified_reference_local_to_enclosing_scope(): + sql = "SELECT * FROM Date AS Date WHERE EXISTS (SELECT 1 FROM Sales WHERE Sales.DateKey = Date.DateKey)" + rewritten = _rewrite_expr_for_alias(sql, "l", source_tables={"Date"}, source_columns={"DateKey"}) + + assert "Sales.DateKey = Date.DateKey" in rewritten + assert "Sales.DateKey = l.DateKey" not in rewritten + + +def test_rewrite_expr_for_alias_rewrites_when_no_enclosing_local_table_scope_exists(): + sql = "SELECT * FROM Sales WHERE Sales.DateKey = Date.DateKey" + rewritten = _rewrite_expr_for_alias(sql, "l", source_tables={"Date"}, source_columns={"DateKey"}) + + assert "Sales.DateKey = l.DateKey" in rewritten + + +def test_rewrite_expr_for_alias_skips_ambiguous_multitable_wildcard_rewrite(): + sql = "SELECT * FROM Sales WHERE Date.DateKey = Sales.DateKey" + rewritten = _rewrite_expr_for_alias( + sql, + "l", + source_tables={"Date", "Product"}, + source_columns={"*"}, + ambiguous_source_columns={"DateKey"}, + ) + + assert "Date.DateKey = Sales.DateKey" in rewritten + assert "l.DateKey = Sales.DateKey" not in rewritten + + +def test_rewrite_expr_for_alias_raises_when_strict_rewrite_cannot_parse(monkeypatch): + import sqlglot + + def _boom(*_args, **_kwargs): + raise ValueError("parse failed") + + monkeypatch.setattr(sqlglot, "parse_one", _boom) + + with pytest.raises(DaxTranslationError, match="Unable to safely correlate outer column references"): + _rewrite_expr_for_alias( + "SELECT Date.DateKey", + "l", + source_tables={"Date"}, + source_columns={"DateKey"}, + allow_fallback=False, + strict_source_resolution=True, + ) + + +def test_translate_query_topnskip(): + query = _parse_query( + """ + EVALUATE + TOPNSKIP(2, 1, 'Sales', 'Sales'[Amount], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "ORDER BY" in sql + assert "DESC" in sql + assert "LIMIT 2 OFFSET 1" in sql + + +def test_translate_query_topn_accepts_scalar_count_expression(): + query = _parse_query( + """ + EVALUATE + TOPN(1 + 1, 'Sales', 'Sales'[Amount], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "ORDER BY" in sql + assert "DESC" in sql + assert "LIMIT CAST(((1 + 1)) AS BIGINT)" in sql + + +def test_translate_query_topnskip_accepts_scalar_skip_expression(): + query = _parse_query( + """ + EVALUATE + TOPNSKIP(3 - 1, 5 / 2, 'Sales', 'Sales'[Amount], ASC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "ORDER BY" in sql + assert "ASC" in sql + assert "LIMIT CAST(((3 - 1)) AS BIGINT)" in sql + assert "OFFSET CAST(((5 / 2)) AS BIGINT)" in sql + + +def test_translate_query_topn_cross_table_order_by_joins_related_table(): + query = _parse_query( + """ + EVALUATE + TOPN(2, 'Sales', 'Product'[Category], ASC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql + assert "ORDER BY Product.Category ASC LIMIT 2" in sql + + +def test_translate_query_topnskip_cross_table_order_by_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + TOPNSKIP(2, 1, 'Sales', 'Tax'[Rate], ASC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales CROSS JOIN Tax ORDER BY Tax.Rate ASC LIMIT 2 OFFSET 1" in sql + + +def test_translate_query_topn_wrapped_base_cross_table_order_by_joins_related_table(): + query = _parse_query( + """ + EVALUATE + TOPN(2, FILTER('Sales', 'Sales'[Amount] > 0), 'Product'[Category], ASC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert ( + "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" + in sql + ) + assert "ORDER BY Product.Category ASC LIMIT 2" in sql + + +def test_translate_query_topnskip_wrapped_base_cross_table_order_by_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + TOPNSKIP(2, 1, FILTER('Sales', 'Sales'[Amount] > 0), 'Tax'[Rate], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert ( + "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t CROSS JOIN Tax ORDER BY Tax.Rate DESC LIMIT 2 OFFSET 1" + in sql + ) + + +def test_translate_query_addmissingitems_with_summarizecolumns(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "ADDMISSINGITEMS" not in sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql + assert "LEFT JOIN (" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "b.* EXCLUDE (FiscalYear)" in sql + assert "AS Revenue" in sql + + +def test_translate_query_addmissingitems_with_multiple_group_columns(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + 'Product'[Category], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + 'Product'[Category], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Product": {"Category": "Category"}, + "Sales": {"Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0, Product.Category AS __addmissingitems_k1" in sql + assert "LEFT JOIN (" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "b.Category IS NOT DISTINCT FROM d.__addmissingitems_k1" in sql + + +def test_translate_query_addmissingitems_showall_column_not_projected_by_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + 'Product'[Category], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Product": {"Category": "Category", "ProductKey": "ProductKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey", "ProductKey": "ProductKey"}, + }, + relationship_edges=[ + RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), + RelationshipEdge("Sales", "ProductKey", "Product", "ProductKey"), + ], + ) + + sql = translation.evaluates[0].sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "b.Category IS NOT DISTINCT FROM d.__addmissingitems_k1" not in sql + assert "AS Revenue" in sql + + +def test_translate_query_addmissingitems_showall_only_with_scalar_table_arg_uses_true_join(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Product'[Category], + ROW("Revenue", 1) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Product": {"Category": "Category"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "ON TRUE" in sql + assert "SELECT DISTINCT Product.Category AS __addmissingitems_k0 FROM Product" in sql + + +def test_translate_query_addmissingitems_applies_trailing_filter_table_to_domain(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + FILTER('Date', 'Date'[Fiscal Year] >= 2024) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "LEFT JOIN (" in sql + + +def test_translate_query_addmissingitems_filter_table_adds_domain_join_tables(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + FILTER('Sales', 'Sales'[Amount] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey WHERE (Sales.Amount > 0)" in sql + assert "LEFT JOIN (" in sql + + +def test_translate_query_addmissingitems_filter_before_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + FILTER('Date', 'Date'[Fiscal Year] >= 2024), + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "LEFT JOIN (" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + + +def test_translate_query_addmissingitems_direct_group_table_before_main_table(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + 'Date', + 'Sales' + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Sales": {"Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql + assert "LEFT JOIN (SELECT * FROM Sales) AS b ON TRUE" in sql + + +def test_translate_query_addmissingitems_direct_group_table_before_calculatetable_main(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + 'Date', + CALCULATETABLE('Sales', 'Sales'[Amount] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Sales": {"Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql + assert "LEFT JOIN (SELECT * FROM Sales WHERE (Sales.Amount > 0)) AS b ON TRUE" in sql + + +def test_translate_query_addmissingitems_direct_group_table_before_nonvisual_calculatetable_main(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + 'Date', + NONVISUAL(CALCULATETABLE('Sales', 'Sales'[Amount] > 0)) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Sales": {"Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql + assert "LEFT JOIN (SELECT * FROM Sales WHERE (Sales.Amount > 0)) AS b ON TRUE" in sql + + +def test_translate_query_addmissingitems_leading_nonvisual_calculatetable_filter_before_main_table(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + NONVISUAL(CALCULATETABLE('Date', 'Date'[Fiscal Year] >= 2024)), + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "LEFT JOIN (SELECT * FROM Date WHERE (Date.FiscalYear >= 2024)) AS b ON TRUE" not in sql + + +def test_translate_query_addmissingitems_trailing_calculatetable_filter(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + CALCULATETABLE('Date', 'Date'[Fiscal Year] >= 2024) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "LEFT JOIN (" in sql + + +def test_translate_query_addmissingitems_prefers_wrapped_summarizecolumns_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + FILTER('Date', 'Date'[Fiscal Year] >= 2023), + CALCULATETABLE( + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + 'Date'[Fiscal Year] >= 2024 + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2023)" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "WHERE (FiscalYear >= 2024)" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert " ON TRUE" not in sql + + +def test_translate_query_addmissingitems_prefers_keepfilters_wrapped_summarize_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + FILTER('Date', 'Date'[Fiscal Year] >= 2024), + KEEPFILTERS( + SUMMARIZE( + 'Sales', + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "LEFT JOIN (SELECT * FROM Date WHERE (FiscalYear >= 2024)) AS b ON TRUE" not in sql + + +def test_translate_query_addmissingitems_prefers_calculatetable_wrapped_summarize_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + FILTER('Date', 'Date'[Fiscal Year] >= 2024), + CALCULATETABLE( + SUMMARIZE( + 'Sales', + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + 'Date'[Fiscal Year] >= 2023 + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "WHERE (Date.FiscalYear >= 2023)" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "LEFT JOIN (SELECT * FROM Date WHERE (FiscalYear >= 2024)) AS b ON TRUE" not in sql + + +def test_translate_query_addmissingitems_prefers_nonvisual_keepfilters_wrapped_summarize_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + FILTER('Date', 'Date'[Fiscal Year] >= 2024), + NONVISUAL( + KEEPFILTERS( + SUMMARIZE( + 'Sales', + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "LEFT JOIN (SELECT * FROM Date WHERE (FiscalYear >= 2024)) AS b ON TRUE" not in sql + + +def test_translate_query_addmissingitems_keeps_first_non_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "LEFT JOIN (" in sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql + + +def test_translate_query_addmissingitems_union_before_main_table_arg(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])), + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "LEFT JOIN (" in sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql + + +def test_translate_query_addmissingitems_union_main_table_with_trailing_filter(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])), + FILTER('Date', 'Date'[Fiscal Year] >= 2024) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, + ) + + sql = translation.evaluates[0].sql + assert ( + "LEFT JOIN (SELECT * FROM (SELECT DISTINCT Date.FiscalYear FROM Date) AS t0 UNION ALL SELECT * FROM (SELECT DISTINCT Date.FiscalYear FROM Date) AS t1) AS b" + in sql + ) + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + + +def test_translate_query_addmissingitems_prefers_non_group_calculatetable_over_leading_group_union(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])), + CALCULATETABLE('Sales', 'Sales'[Amount] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Sales": {"Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql + assert "FROM Date CROSS JOIN Sales WHERE (Sales.Amount > 0)" not in sql + assert "LEFT JOIN (SELECT * FROM Sales WHERE (Sales.Amount > 0)) AS b ON TRUE" in sql + + +def test_translate_query_addmissingitems_prefers_summarize_over_leading_union_candidate(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + UNION(VALUES('Sales'[DateKey]), VALUES('Sales'[DateKey])), + SUMMARIZE( + 'Sales', + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "LEFT JOIN (SELECT * FROM (SELECT DISTINCT Sales.DateKey FROM Sales) AS t0 UNION ALL" not in sql + + +def test_translate_query_addmissingitems_prefers_wrapped_summarize_over_leading_union_candidate(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + UNION(VALUES('Sales'[DateKey]), VALUES('Sales'[DateKey])), + CALCULATETABLE( + SUMMARIZE( + 'Sales', + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + 'Date'[Fiscal Year] >= 2024 + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "WHERE (Date.FiscalYear >= 2024)" in sql + assert "LEFT JOIN (SELECT * FROM (SELECT DISTINCT Sales.DateKey FROM Sales) AS t0 UNION ALL" not in sql + + +@pytest.mark.parametrize( + "leading_set_expr", + [ + "UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", + "INTERSECT(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", + "EXCEPT(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", + "CROSSJOIN(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", + "NATURALINNERJOIN(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", + "NATURALLEFTOUTERJOIN(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", + ], +) +def test_translate_query_addmissingitems_prefers_summarize_over_leading_set_or_join_candidate(leading_set_expr: str): + query = _parse_query( + f""" + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + {leading_set_expr}, + SUMMARIZE( + 'Sales', + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear"}, + "Sales": {"Amount": "Amount"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + + +def test_translate_query_addmissingitems_supports_trailing_group_by_column(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + 'Product'[Category], + "Revenue", SUM('Sales'[Amount]) + ), + 'Product'[Category] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Product": {"Category": "Category", "ProductKey": "ProductKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey", "ProductKey": "ProductKey"}, + }, + relationship_edges=[ + RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), + RelationshipEdge("Sales", "ProductKey", "Product", "ProductKey"), + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0, Product.Category AS __addmissingitems_k1" in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + assert "b.Category IS NOT DISTINCT FROM d.__addmissingitems_k1" in sql + + +def test_translate_query_addmissingitems_deduplicates_repeated_group_column(): + query = _parse_query( + """ + EVALUATE + ADDMISSINGITEMS( + 'Date'[Fiscal Year], + SUMMARIZECOLUMNS( + 'Date'[Fiscal Year], + "Revenue", SUM('Sales'[Amount]) + ), + FILTER('Date', 'Date'[Fiscal Year] >= 2024), + 'Date'[Fiscal Year] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + }, + relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql + assert "__addmissingitems_k1" not in sql + assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql + + +def test_translate_query_groupby_with_currentgroup_iterators(): + query = _parse_query( + """ + EVALUATE + GROUPBY( + 'Sales', + 'Sales'[Category], + "Revenue", SUMX(CURRENTGROUP(), 'Sales'[Amount]), + "Rows", COUNTROWS(CURRENTGROUP()) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "GROUP BY" in sql + assert "SUM(Sales.Amount) AS Revenue" in sql + assert "COUNT(*) AS Rows" in sql + + +def test_translate_query_datatable(): + query = _parse_query( + """ + EVALUATE + DATATABLE( + "k", INTEGER, + "v", STRING, + {{1, "a"}, {2, "b"}} + ) + """ + ) + + translation = translate_dax_query(query) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM (VALUES (1, 'a'), (2, 'b')) AS t(k, v)" in sql + + +def test_translate_query_topnperlevel(): + query = _parse_query( + """ + EVALUATE + TOPNPERLEVEL(2, 'Sales'[Category], 'Sales', 'Sales'[Amount], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "RANK() OVER (PARTITION BY Sales.Category ORDER BY Amount DESC)" in sql + assert "__topnperlevel_rank <= 2" in sql + + +def test_translate_query_topnperlevel_multiple_group_columns(): + query = _parse_query( + """ + EVALUATE + TOPNPERLEVEL(1, 'Sales'[Category], 'Sales'[Region], 'Sales', 'Sales'[Amount], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Category": "Category", "Region": "Region", "Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "PARTITION BY Sales.Category, Sales.Region" in sql + assert "ORDER BY Amount DESC" in sql + + +def test_translate_query_topnperlevel_cross_table_group_order_joins_related_table(): + query = _parse_query( + """ + EVALUATE + TOPNPERLEVEL(1, 'Product'[Category], 'Sales', 'Product'[Category], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.*, RANK() OVER (PARTITION BY Product.Category ORDER BY Product.Category DESC)" in sql + assert "FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql + + +def test_translate_query_topnperlevel_wrapped_base_cross_table_group_order_joins_related_table(): + query = _parse_query( + """ + EVALUATE + TOPNPERLEVEL(1, 'Product'[Category], FILTER('Sales', 'Sales'[Amount] > 0), 'Product'[Category], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT t.*, RANK() OVER (PARTITION BY Product.Category ORDER BY Product.Category DESC)" in sql + assert ( + "FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" + in sql + ) + + +def test_translate_query_topnperlevel_wrapped_base_cross_table_group_order_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + TOPNPERLEVEL(1, 'Tax'[Region], FILTER('Sales', 'Sales'[Amount] > 0), 'Tax'[Rate], DESC) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Region": "Region", "Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT t.*, RANK() OVER (PARTITION BY Tax.Region ORDER BY Tax.Rate DESC)" in sql + assert "FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t CROSS JOIN Tax" in sql + + +def test_translate_query_define_table_is_resolved(): + query = _parse_query( + """ + DEFINE + TABLE MyTable = FILTER('Sales', 'Sales'[Amount] > 100) + EVALUATE + MyTable + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Amount > 100)" in sql + + +def test_translate_query_evaluate_filter_with_cross_table_predicate_joins_related_table(): + query = _parse_query( + """ + EVALUATE + FILTER('Sales', 'Product'[Category] = "Clothing") + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql + assert "WHERE (Product.Category = 'Clothing')" in sql + + +def test_translate_query_evaluate_filter_with_cross_table_predicate_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + FILTER('Sales', 'Tax'[Rate] > 0) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql + + +def test_translate_query_summarizecolumns_filter_derived_table_alias_predicate_uses_exists_subquery(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Amount], + FILTER({ ('Sales'[Amount], 'Date'[DateKey]) }, [value1] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "WHERE EXISTS (SELECT 1 FROM (" in sql + assert "SELECT * FROM (SELECT" in sql + assert "AS value1" in sql + assert "AS value2 FROM Sales CROSS JOIN Date) AS t WHERE (value1 > 0)" in sql + assert "Sales.value1" not in sql + assert "FROM Sales CROSS JOIN Date WHERE EXISTS" not in sql + assert "FROM Sales WHERE EXISTS" in sql + + +def test_translate_query_summarizecolumns_filter_values_known_column_stays_direct_predicate(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[ProductKey], + FILTER(VALUES('Sales'[ProductKey]), 'Sales'[ProductKey] > 1) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, + ) + + sql = translation.evaluates[0].sql + assert "ProductKey > 1" in sql + assert "EXISTS (SELECT 1 FROM (" not in sql + + +def test_translate_query_summarizecolumns_filter_selectcolumns_alias_predicate_rewrites_directly(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Sales'[Amount], + FILTER(SELECTCOLUMNS('Sales', "x", 'Sales'[Amount]), [x] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "WHERE (Sales.Amount > 0)" in sql + assert "EXISTS (SELECT 1 FROM (" not in sql + + +def test_translate_query_evaluate_calculatetable_base_table_selectcolumns_alias_filter_rewrites_directly(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + 'Sales', + FILTER(SELECTCOLUMNS('Sales', "x", 'Sales'[Amount]), [x] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Sales.Amount > 0)" in sql + assert "EXISTS (SELECT 1 FROM (" not in sql + + +def test_translate_query_evaluate_calculatetable(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE('Sales', 'Sales'[Amount] > 100) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql + + +def test_translate_query_evaluate_calculatetable_base_table_cross_table_filter_joins_related_table(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE('Sales', 'Product'[Category] = "Clothing") + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql + assert "WHERE (Product.Category = 'Clothing')" in sql + + +def test_translate_query_evaluate_calculatetable_base_table_cross_table_table_ref_filter_candidate(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE('Sales', 'Date') + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales" in sql + assert "Date" in translation.evaluates[0].required_models + + +def test_translate_query_evaluate_calculatetable_base_table_cross_table_filter_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE('Sales', 'Tax'[Rate] > 0) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql + + +def test_translate_query_evaluate_calculatetable_base_table_cross_table_datesinperiod_filter_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE('Sales', DATESINPERIOD('Date'[DateKey], "2024-12-31", -3, MONTH)) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales CROSS JOIN Date" in sql + assert "WHERE (Date.DateKey > ('2024-12-31' + INTERVAL '-3 month') AND Date.DateKey <= '2024-12-31')" in sql + + +def test_translate_query_evaluate_calculatetable_base_table_cross_table_datesytd_filter_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE('Sales', DATESYTD('Date'[DateKey])) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales CROSS JOIN Date" in sql + assert ( + "WHERE (Date.DateKey >= DATE_TRUNC('year', (SELECT MAX(Date.DateKey) FROM Date)) " + "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" + ) in sql + + +def test_translate_query_evaluate_calculatetable_base_table_cross_table_dateadd_filter_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE('Sales', DATEADD('Date'[DateKey], -1, YEAR)) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Sales.* FROM Sales CROSS JOIN Date" in sql + assert ( + "WHERE (Date.DateKey > ((SELECT MAX(Date.DateKey) FROM Date) + INTERVAL '-1 year') " + "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" + ) in sql + + +def test_translate_query_evaluate_calculatetable_base_table_derived_alias_filter_does_not_expand_outer_from(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + 'Sales', + FILTER({ ('Sales'[Amount], 'Date'[DateKey]) }, [value1] > 0) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Date": {"DateKey": "DateKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE EXISTS" in sql + assert "FROM Sales CROSS JOIN Date WHERE EXISTS" not in sql + assert "AS value2 FROM Sales CROSS JOIN Date) AS t WHERE (value1 > 0)" in sql + + +def test_translate_query_evaluate_summarizecolumns_cross_table_dateadd_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + DATEADD('Date'[DateKey], -1, YEAR), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Date.FiscalYear, (SELECT COUNT(*) FROM Sales) AS Rows FROM Date" in sql + assert ( + "WHERE (Date.DateKey > ((SELECT MAX(Date.DateKey) FROM Date) + INTERVAL '-1 year') " + "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" + ) in sql + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_evaluate_summarizecolumns_cross_table_datesytd_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + DATESYTD('Date'[DateKey]), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Date.FiscalYear, (SELECT COUNT(*) FROM Sales) AS Rows FROM Date" in sql + assert ( + "WHERE (Date.DateKey >= DATE_TRUNC('year', (SELECT MAX(Date.DateKey) FROM Date)) " + "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" + ) in sql + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_evaluate_summarizecolumns_cross_table_datesinperiod_filter_table_arg(): + query = _parse_query( + """ + EVALUATE + SUMMARIZECOLUMNS( + 'Date'[FiscalYear], + DATESINPERIOD('Date'[DateKey], "2024-12-31", -3, MONTH), + "Rows", COUNTROWS('Sales') + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"DateKey": "DateKey"}, + "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Date.FiscalYear, (SELECT COUNT(*) FROM Sales) AS Rows FROM Date" in sql + assert "WHERE (Date.DateKey > ('2024-12-31' + INTERVAL '-3 month') AND Date.DateKey <= '2024-12-31')" in sql + assert "GROUP BY Date.FiscalYear" in sql + + +def test_translate_query_evaluate_calculatetable_wrapped_base(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount] > 200) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t WHERE (Amount > 200)" in sql + + +def test_translate_query_evaluate_calculatetable_wrapped_base_cross_table_filter_joins_related_table(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + FILTER('Sales', 'Sales'[Amount] > 100), + 'Product'[Category] = "Clothing" + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert ( + "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" + in sql + ) + assert "WHERE (Product.Category = 'Clothing')" in sql + + +def test_translate_query_evaluate_calculatetable_wrapped_base_cross_table_filter_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + FILTER('Sales', 'Sales'[Amount] > 100), + 'Tax'[Rate] > 0 + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql + + +def test_translate_query_evaluate_filter_wrapped_base_cross_table_filter_joins_related_table(): + query = _parse_query( + """ + EVALUATE + FILTER( + FILTER('Sales', 'Sales'[Amount] > 100), + 'Product'[Category] = "Clothing" + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Product": {"ProductKey": "ProductKey", "Category": "Category"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Product", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert ( + "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" + in sql + ) + assert "WHERE (Product.Category = 'Clothing')" in sql + + +def test_translate_query_evaluate_filter_wrapped_base_cross_table_filter_cross_join_when_unrelated(): + query = _parse_query( + """ + EVALUATE + FILTER( + FILTER('Sales', 'Sales'[Amount] > 100), + 'Tax'[Rate] > 0 + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql + + +def test_translate_query_evaluate_nested_calculatetable_replaces_same_column_filter(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE(CALCULATETABLE('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount] > 200) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Sales.Amount > 200)" in sql + assert "(Sales.Amount > 100)" not in sql + + +def test_translate_query_evaluate_values_column(): + query = _parse_query( + """ + EVALUATE + VALUES('Sales'[Amount]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT Sales.Amount FROM Sales" in sql + + +def test_translate_query_evaluate_values_multitable_scalar(): + query = _parse_query( + """ + EVALUATE + VALUES('Sales'[Amount] + 'Products'[Weight]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, + "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Products", + to_column="ProductKey", + ) + ], + ) + + sql = translation.evaluates[0].sql + assert "SELECT DISTINCT (Sales.Amount + Products.Weight)" in sql + assert "LEFT JOIN Products ON Sales.ProductKey = Products.ProductKey" in sql + + +def test_translate_query_evaluate_removecolumns_table(): + query = _parse_query( + """ + EVALUATE + REMOVECOLUMNS('Sales', 'Sales'[Amount]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * EXCLUDE (Amount) FROM (SELECT * FROM Sales) AS t" in sql + + +def test_translate_query_evaluate_keepcolumns_table(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS('Sales', 'Sales'[Amount]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT t.Amount FROM (SELECT * FROM Sales) AS t" in sql + + +def test_translate_query_evaluate_keepcolumns_wrapped_table_with_named_column(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS( + SELECTCOLUMNS('Sales', "Amt", 'Sales'[Amount], "Qty", 'Sales'[Quantity]), + "Qty" + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Amount AS Amt, Quantity AS Qty FROM Sales" in sql + assert "SELECT t.Qty FROM (" in sql + + +def test_translate_query_evaluate_keepcolumns_addcolumns_preserves_base_star_columns(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS( + ADDCOLUMNS('Sales', "W", 1), + 'Sales'[Amount] + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT *, 1 AS W FROM Sales" in sql + assert "SELECT t.Amount FROM (" in sql + + +def test_translate_query_evaluate_calculatetable_keepcolumns_preserves_underlying_filters(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + 'Sales', + KEEPCOLUMNS(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql + + +def test_translate_query_evaluate_keepcolumns_requires_column_arg(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS('Sales') + """ + ) + + with pytest.raises( + DaxTranslationError, match="KEEPCOLUMNS requires a table expression and at least one column argument" + ): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_keepcolumns_rejects_missing_input_column(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS('Sales', 'Products'[Weight]) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="KEEPCOLUMNS column 'Weight' references table 'Products' not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + ) + + +def test_translate_query_evaluate_keepcolumns_rejects_wrong_qualified_table_reference(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS('Sales', 'Products'[Amount]) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="KEEPCOLUMNS column 'Amount' references table 'Products' not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Amount": "Amount", "Weight": "Weight"}, + }, + ) + + +def test_translate_query_evaluate_keepcolumns_rejects_unprojected_related_column_in_addcolumns_input(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS( + ADDCOLUMNS('Sales', "W", 'Products'[Weight]), + 'Products'[Weight] + ) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="KEEPCOLUMNS column 'Weight' is not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Products", + to_column="ProductKey", + ) + ], + ) + + +def test_translate_query_evaluate_keepcolumns_rejects_unprojected_related_column_in_wrapped_addcolumns_input(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS( + ADDCOLUMNS( + FILTER('Sales', 'Sales'[Amount] > 0), + "W", 'Products'[Weight] + ), + 'Products'[Weight] + ) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="KEEPCOLUMNS column 'Weight' is not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + relationship_edges=[ + RelationshipEdge( + from_table="Sales", + from_column="ProductKey", + to_table="Products", + to_column="ProductKey", + ) + ], + ) + + +def test_translate_query_evaluate_keepcolumns_rejects_ambiguous_duplicate_input_column(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS(CROSSJOIN('Sales', 'Products'), "ProductKey") + """ + ) + + with pytest.raises( + DaxTranslationError, + match="KEEPCOLUMNS column 'ProductKey' is ambiguous in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + ) + + +def test_translate_query_evaluate_keepcolumns_accepts_qualified_unique_column_from_multitable_input(): + query = _parse_query( + """ + EVALUATE + KEEPCOLUMNS(CROSSJOIN('Sales', 'Products'), 'Products'[Weight]) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + ) + + sql = translation.evaluates[0].sql + assert "SELECT t.Weight FROM (" in sql + + +def test_translate_query_evaluate_removecolumns_wrapped_table_with_named_column(): + query = _parse_query( + """ + EVALUATE + REMOVECOLUMNS( + SELECTCOLUMNS('Sales', "Amt", 'Sales'[Amount], "Qty", 'Sales'[Quantity]), + "Qty" + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Amount AS Amt, Quantity AS Qty FROM Sales" in sql + assert "SELECT * EXCLUDE (Qty) FROM (" in sql + + +def test_translate_query_evaluate_calculatetable_removecolumns_preserves_underlying_filters(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + 'Sales', + REMOVECOLUMNS(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount]) + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql + + +def test_translate_query_evaluate_removecolumns_requires_column_arg(): + query = _parse_query( + """ + EVALUATE + REMOVECOLUMNS('Sales') + """ + ) + + with pytest.raises( + DaxTranslationError, match="REMOVECOLUMNS requires a table expression and at least one column argument" + ): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_removecolumns_rejects_missing_input_column(): + query = _parse_query( + """ + EVALUATE + REMOVECOLUMNS('Sales', 'Products'[Weight]) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="REMOVECOLUMNS column 'Weight' references table 'Products' not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + ) + + +def test_translate_query_evaluate_removecolumns_rejects_wrong_qualified_table_reference(): + query = _parse_query( + """ + EVALUATE + REMOVECOLUMNS('Sales', 'Products'[Amount]) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="REMOVECOLUMNS column 'Amount' references table 'Products' not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Amount": "Amount", "Weight": "Weight"}, + }, + ) + + +def test_translate_query_evaluate_removecolumns_rejects_ambiguous_duplicate_input_column(): + query = _parse_query( + """ + EVALUATE + REMOVECOLUMNS(CROSSJOIN('Sales', 'Products'), "ProductKey") + """ + ) + + with pytest.raises( + DaxTranslationError, + match="REMOVECOLUMNS column 'ProductKey' is ambiguous in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + ) + + +def test_translate_query_evaluate_renamecolumns_table(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS('Sales', 'Sales'[Amount], "Amt") + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * RENAME (Amount AS Amt) FROM (SELECT * FROM Sales) AS t" in sql + + +def test_translate_query_evaluate_renamecolumns_wrapped_table(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS( + SELECTCOLUMNS('Sales', "Amt", 'Sales'[Amount], "Qty", 'Sales'[Quantity]), + "Qty", "QuantityRenamed" + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT Amount AS Amt, Quantity AS Qty FROM Sales" in sql + assert "SELECT * RENAME (Qty AS QuantityRenamed) FROM (" in sql + + +def test_translate_query_evaluate_calculatetable_renamecolumns_preserves_underlying_filters(): + query = _parse_query( + """ + EVALUATE + CALCULATETABLE( + 'Sales', + RENAMECOLUMNS(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount], "Amt") + ) + """ + ) + + translation = translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + sql = translation.evaluates[0].sql + assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql + + +def test_translate_query_evaluate_renamecolumns_requires_old_new_pairs(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS('Sales', 'Sales'[Amount]) + """ + ) + + with pytest.raises( + DaxTranslationError, + match="RENAMECOLUMNS requires a table expression and at least one old/new column pair", + ): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_evaluate_renamecolumns_requires_even_old_new_pairs(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS('Sales', 'Sales'[Amount], "Amt", 'Sales'[Quantity]) + """ + ) + + with pytest.raises(DaxTranslationError, match="RENAMECOLUMNS requires old/new column argument pairs"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + +def test_translate_query_evaluate_renamecolumns_rejects_missing_input_source_column(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS('Sales', 'Products'[Weight], "WeightRenamed") + """ + ) + + with pytest.raises( + DaxTranslationError, + match="RENAMECOLUMNS column 'Weight' references table 'Products' not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + ) + + +def test_translate_query_evaluate_renamecolumns_rejects_wrong_qualified_source_table_reference(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS('Sales', 'Products'[Amount], "Amt") + """ + ) + + with pytest.raises( + DaxTranslationError, + match="RENAMECOLUMNS column 'Amount' references table 'Products' not present in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Amount": "Amount", "Weight": "Weight"}, + }, + ) + + +def test_translate_query_evaluate_renamecolumns_rejects_duplicate_source_columns(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS('Sales', 'Sales'[Amount], "Amt", 'Sales'[Amount], "AmountAgain") + """ + ) + + with pytest.raises(DaxTranslationError, match="RENAMECOLUMNS source columns must be unique"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + +def test_translate_query_evaluate_renamecolumns_rejects_duplicate_target_columns(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS('Sales', 'Sales'[Amount], "Value", 'Sales'[Quantity], "Value") + """ + ) + + with pytest.raises(DaxTranslationError, match="RENAMECOLUMNS target column names must be unique"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, + ) + + +def test_translate_query_evaluate_renamecolumns_rejects_ambiguous_duplicate_input_source_column(): + query = _parse_query( + """ + EVALUATE + RENAMECOLUMNS(CROSSJOIN('Sales', 'Products'), "ProductKey", "KeyRenamed") + """ + ) + + with pytest.raises( + DaxTranslationError, + match="RENAMECOLUMNS source column 'ProductKey' is ambiguous in input table expression", + ): + translate_dax_query( + query, + column_sql_by_table={ + "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, + "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, + }, + ) + + +def test_translate_query_define_function_arity_error(): + query = _parse_query( + """ + DEFINE + FUNCTION f = (x : NUMERIC) => x + 1 + EVALUATE + SELECTCOLUMNS('Sales', "x", f('Sales'[Amount], 2)) + """ + ) + + with pytest.raises(DaxTranslationError, match="expects 1 args, got 2"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) + + +def test_translate_query_define_function_cycle_error(): + query = _parse_query( + """ + DEFINE + FUNCTION loop = (x : NUMERIC) => loop(x) + EVALUATE + SELECTCOLUMNS('Sales', "x", loop('Sales'[Amount])) + """ + ) + + with pytest.raises(DaxTranslationError, match="Cyclic DEFINE FUNCTION reference"): + translate_dax_query( + query, + column_sql_by_table={"Sales": {"Amount": "Amount"}}, + ) diff --git a/tests/dax/test_runtime.py b/tests/dax/test_runtime.py new file mode 100644 index 00000000..c8cc7e54 --- /dev/null +++ b/tests/dax/test_runtime.py @@ -0,0 +1,138 @@ +"""Tests for DAX runtime translation context helpers.""" + +from sidemantic.core.metric import Metric +from sidemantic.core.model import Model +from sidemantic.core.relationship import Relationship +from sidemantic.core.semantic_graph import SemanticGraph +from sidemantic.dax.runtime import build_dax_translation_context + + +def test_build_dax_translation_context_includes_many_to_many_edges(): + sales = Model( + name="Sales", + table="sales", + primary_key="SalesKey", + relationships=[ + Relationship( + name="Products", + type="many_to_many", + foreign_key="ProductKey", + primary_key="ProductKey", + ) + ], + ) + products = Model(name="Products", table="products", primary_key="ProductKey") + + graph = SemanticGraph() + graph.add_model(sales) + graph.add_model(products) + + context = build_dax_translation_context(graph) + edges = context["relationship_edges"] + assert len(edges) == 1 + assert edges[0].from_table == "Sales" + assert edges[0].from_column == "ProductKey" + assert edges[0].to_table == "Products" + assert edges[0].to_column == "ProductKey" + + +def test_build_dax_translation_context_deduplicates_reverse_relationship_edges(): + sales = Model( + name="Sales", + table="sales", + primary_key="SalesKey", + relationships=[ + Relationship( + name="Products", + type="many_to_many", + foreign_key="ProductKey", + primary_key="ProductKey", + ) + ], + ) + products = Model( + name="Products", + table="products", + primary_key="ProductKey", + relationships=[ + Relationship( + name="Sales", + type="many_to_many", + foreign_key="ProductKey", + primary_key="ProductKey", + ) + ], + ) + + graph = SemanticGraph() + graph.add_model(sales) + graph.add_model(products) + + context = build_dax_translation_context(graph) + edges = context["relationship_edges"] + assert len(edges) == 1 + + +def test_build_dax_translation_context_uses_tmdl_from_column_for_one_to_many_edges(): + products_to_customers = Relationship( + name="Customers", + type="one_to_many", + foreign_key="ProductKey", + ) + products_to_customers._tmdl_from_column = "ProductKey" + products = Model( + name="Products", + table="products", + primary_key="InternalProductId", + relationships=[products_to_customers], + ) + customers = Model(name="Customers", table="customers", primary_key="CustomerKey") + + graph = SemanticGraph() + graph.add_model(products) + graph.add_model(customers) + + context = build_dax_translation_context(graph) + edges = context["relationship_edges"] + assert len(edges) == 1 + assert edges[0].from_table == "Products" + assert edges[0].from_column == "ProductKey" + assert edges[0].to_table == "Customers" + assert edges[0].to_column == "ProductKey" + + +def test_build_dax_translation_context_includes_measure_sql_metadata(): + sales = Model( + name="Sales", + table="sales", + primary_key="SalesKey", + metrics=[Metric(name="Total Sales", agg="sum", sql="amount")], + ) + + graph = SemanticGraph() + graph.add_model(sales) + + context = build_dax_translation_context(graph) + assert context["measure_sql_by_table"]["Sales"]["Total Sales"] == "amount" + + +def test_build_dax_translation_context_includes_measure_filters(): + sales = Model( + name="Sales", + table="sales", + primary_key="SalesKey", + metrics=[ + Metric( + name="West Sales", + agg="sum", + sql="Amount", + filters=["Sales.Region = 'West'"], + ) + ], + ) + + graph = SemanticGraph() + graph.add_model(sales) + + context = build_dax_translation_context(graph) + assert context["measure_filters_by_table"]["Sales"]["West Sales"] == ["Sales.Region = 'West'"] diff --git a/tests/dax/test_translation.py b/tests/dax/test_translation.py new file mode 100644 index 00000000..e5f1925c --- /dev/null +++ b/tests/dax/test_translation.py @@ -0,0 +1,6070 @@ +from __future__ import annotations + +import pytest +import sidemantic_dax + +from sidemantic.dax import ( + DaxTranslationError, + RelationshipEdge, + translate_dax_metric, + translate_dax_query, + translate_dax_scalar, + translate_dax_table, +) + + +def _parse_expression(expr: str): + try: + return sidemantic_dax.parse_expression(expr) + except RuntimeError as exc: + if "native module is not available" in str(exc): + pytest.skip("sidemantic_dax native module not available") + raise + + +def _parse_query(query: str): + try: + return sidemantic_dax.parse_query(query) + except RuntimeError as exc: + if "native module is not available" in str(exc): + pytest.skip("sidemantic_dax native module not available") + raise + + +def _sales_maps(): + column_sql_by_table = { + "sales": { + "amount": "amount", + "product_key": "product_key", + "quantity": "quantity", + "order_date": "order_date", + } + } + measure_names_by_table = {"sales": {"Total Sales"}} + time_dimensions_by_table = {"sales": {"order_date"}} + return column_sql_by_table, measure_names_by_table, time_dimensions_by_table + + +def test_translate_calculate_with_filter(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_metric_reference_uses_measure_metadata(): + expr = _parse_expression("[Total Sales]") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, + measure_sql_by_table={"sales": {"Total Sales": "amount"}}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.source_table == "sales" + + +def test_translate_scalar_allows_multi_table_when_model_is_none(): + expr = _parse_expression("'Sales'[Amount] + 'Tax'[Rate]") + sql = translate_dax_scalar( + expr, + model_name=None, + column_sql_by_table={ + "Sales": {"Amount": "Amount"}, + "Tax": {"Rate": "Rate"}, + }, + ) + + assert sql == "(Sales.Amount + Tax.Rate)" + + +def test_translate_calculate_filter_argument_propagates_table_filters(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "FILTER(CALCULATETABLE('sales', 'sales'[quantity] = 2), 'sales'[product_key] = 1)" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(quantity = 2)", "(product_key = 1)"] + + +def test_translate_calculate_keepfilters_filter_argument_preserves_inherited_filter(): + expr = _parse_expression( + "CALCULATE(" + "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 3), " + "KEEPFILTERS(FILTER(CALCULATETABLE('sales', 'sales'[quantity] = 2), 'sales'[product_key] = 1))" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 3)", "(quantity = 2)", "(product_key = 1)"] + + +def test_translate_derived_countrows_table_expression_keeps_filters(): + expr = _parse_expression("DIVIDE(COUNTROWS(CALCULATETABLE('sales', 'sales'[product_key] = 1)), COUNTROWS('sales'))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert "COUNT(CASE WHEN (product_key = 1) THEN 1 END)" in translation.sql + assert "COUNT(*)" in translation.sql + + +def test_translate_sumx_row_expression(): + expr = _parse_expression("SUMX('sales', 'sales'[amount] * 'sales'[quantity])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "(amount * quantity)" + + +def test_translate_sumx_filter_all_expression(): + expr = _parse_expression("SUMX(FILTER(ALL('sales'), 'sales'[product_key] = 1), 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_averagex_cross_table_row_expression(): + expr = _parse_expression("AVERAGEX('sales', 'sales'[amount] * 'products'[weight])") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "order_date": "order_date"}, + "products": {"weight": "weight"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "avg" + assert translation.sql == "(amount * products.weight)" + assert "products" in translation.required_models + + +def test_translate_median_aggregate_expression(): + expr = _parse_expression("MEDIAN('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "median" + assert translation.sql == "amount" + + +def test_translate_medianx_row_expression(): + expr = _parse_expression("MEDIANX('sales', 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "median" + assert translation.sql == "amount" + + +def test_translate_sumx_topn_table_expression(): + expr = _parse_expression("SUMX(TOPN(10, 'sales', 'sales'[amount], DESC), 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + + +def test_translate_sumx_selectcolumns_filter_expression(): + expr = _parse_expression( + "SUMX(SELECTCOLUMNS(FILTER('sales', 'sales'[product_key] = 1), \"Amount\", 'sales'[amount]), 'sales'[amount])" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_sumx_addcolumns_filter_expression(): + expr = _parse_expression("SUMX(ADDCOLUMNS(FILTER('sales', 'sales'[product_key] = 1), \"X\", 1), 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_sumx_topn_over_filtered_table_expression(): + expr = _parse_expression( + "SUMX(TOPN(5, FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount], DESC), 'sales'[amount])" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_avgx_topn_over_filtered_table_expression(): + expr = _parse_expression( + "AVGX(TOPN(5, FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount], DESC), 'sales'[amount])" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "avg" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_maxx_row_expression(): + expr = _parse_expression("MAXX('sales', 'sales'[amount] * 'sales'[quantity])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "max" + assert translation.sql == "(amount * quantity)" + + +def test_translate_countx_filtered_table_expression(): + expr = _parse_expression("COUNTX(FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_countax_filtered_table_expression(): + expr = _parse_expression("COUNTAX(FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_totalytd_cumulative(): + expr = _parse_expression("TOTALYTD(SUM('sales'[amount]), 'sales'[order_date])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.grain_to_date == "year" + assert translation.agg == "sum" + assert translation.sql == "amount" + + +def test_translate_totalytd_preserves_inherited_filters(): + expr = _parse_expression("TOTALYTD(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), 'sales'[order_date])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.grain_to_date == "year" + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_totalytd_filter_arg_replaces_inherited_filter(): + expr = _parse_expression( + "TOTALYTD(" + "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), " + "'sales'[order_date], " + "'sales'[product_key] = 2" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.filters == ["(product_key = 2)"] + + +def test_translate_totalytd_ignores_year_end_literal_arg(): + expr = _parse_expression("TOTALYTD(SUM('sales'[amount]), 'sales'[order_date], \"6/30\")") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.filters == [] + + +def test_translate_totalmtd_cumulative_with_filter_arg(): + expr = _parse_expression("TOTALMTD(SUM('sales'[amount]), 'sales'[order_date], 'sales'[product_key] = 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.grain_to_date == "month" + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_totalwtd_cumulative_with_filter_arg(): + expr = _parse_expression("TOTALWTD(SUM('sales'[amount]), 'sales'[order_date], 'sales'[product_key] = 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.grain_to_date == "week" + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_totalytd_cross_table_time_column_and_table_filter_candidate(): + expr = _parse_expression("TOTALYTD(SUM('sales'[amount]), 'date'[date_key], 'date')") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, + ) + + assert translation.type == "cumulative" + assert translation.grain_to_date == "year" + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.window_order == "date.date_key" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_totalqtd_preserves_inherited_filters(): + expr = _parse_expression("TOTALQTD(CALCULATE(SUM('sales'[amount]), 'sales'[quantity] = 2), 'sales'[order_date])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.grain_to_date == "quarter" + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(quantity = 2)"] + + +def test_translate_calculate_sameperiodlastyear(): + expr = _parse_expression("CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[order_date]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + + +def test_translate_calculate_sameperiodlastyear_cross_table_time_column(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), SAMEPERIODLASTYEAR('date'[date_key]))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, + ) + + assert translation.type == "time_comparison" + assert translation.inline_base_agg == "sum" + assert translation.inline_base_sql == "amount" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.window_order == "date.date_key" + assert "date" in translation.required_models + + +def test_translate_calculate_datesytd_as_cumulative(): + expr = _parse_expression("CALCULATE([Total Sales], DATESYTD('sales'[order_date]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.sql == "sales.Total Sales" + assert translation.agg == "sum" + assert translation.grain_to_date == "year" + + +def test_translate_calculate_datesmtd_inline_agg_as_cumulative(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DATESMTD('sales'[order_date]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.sql == "amount" + assert translation.agg == "sum" + assert translation.grain_to_date == "month" + + +def test_translate_calculate_dateswtd_inline_agg_as_cumulative(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DATESWTD('sales'[order_date]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.sql == "amount" + assert translation.agg == "sum" + assert translation.grain_to_date == "week" + + +def test_translate_calculate_datesinperiod_inline_agg_as_cumulative_window(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), DATESINPERIOD('sales'[order_date], MAX('sales'[order_date]), -3, MONTH))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.sql == "amount" + assert translation.agg == "sum" + assert translation.window == "3 month" + + +def test_translate_calculate_datesinperiod_measure_ref_as_cumulative_window(): + expr = _parse_expression( + "CALCULATE([Total Sales], DATESINPERIOD('sales'[order_date], MAX('sales'[order_date]), -1, YEAR))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.sql == "sales.Total Sales" + assert translation.agg == "sum" + assert translation.window == "1 year" + + +def test_translate_calculate_datesinperiod_positive_inline_agg_as_forward_cumulative_window(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), DATESINPERIOD('sales'[order_date], MAX('sales'[order_date]), 3, MONTH))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.sql == "amount" + assert translation.agg == "sum" + assert translation.window == "3 month following" + + +def test_translate_calculate_keepfilters_datesqtd_with_additional_filter(): + expr = _parse_expression( + "CALCULATE([Total Sales], KEEPFILTERS(DATESQTD('sales'[order_date])), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "cumulative" + assert translation.sql == "sales.Total Sales" + assert translation.agg == "sum" + assert translation.grain_to_date == "quarter" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_sameperiodlastyear_with_inline_aggregate_base(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), SAMEPERIODLASTYEAR('sales'[order_date]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric is None + assert translation.inline_base_agg == "sum" + assert translation.inline_base_sql == "amount" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + + +def test_translate_calculate_sameperiodlastyear_with_additional_filter(): + expr = _parse_expression( + "CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[order_date]), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_time_intelligence_allows_additional_time_filter_function(): + expr = _parse_expression( + "CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[order_date]), DATESYTD('sales'[order_date]))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.filters + assert any("order_date" in clause for clause in translation.filters or []) + + +def test_translate_calculate_dateadd_with_additional_filter(): + expr = _parse_expression( + "CALCULATE([Total Sales], DATEADD('sales'[order_date], -1, YEAR), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.time_offset == "1 year" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_dateadd_forward_offset_sets_negative_time_offset(): + expr = _parse_expression( + "CALCULATE([Total Sales], DATEADD('sales'[order_date], 1, YEAR), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.time_offset == "-1 year" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_dateadd_plural_unit_normalizes_to_yoy(): + expr = _parse_expression( + "CALCULATE([Total Sales], DATEADD('sales'[order_date], -1, YEARS), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.time_offset == "1 year" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_parallelperiod_with_additional_filter(): + expr = _parse_expression( + "CALCULATE([Total Sales], PARALLELPERIOD('sales'[order_date], -1, YEAR), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.time_offset == "1 year" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_previousyear_with_additional_filter(): + expr = _parse_expression("CALCULATE([Total Sales], PREVIOUSYEAR('sales'[order_date]), 'sales'[product_key] = 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.time_offset == "1 year" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_nextmonth_sets_negative_time_offset(): + expr = _parse_expression("CALCULATE([Total Sales], NEXTMONTH('sales'[order_date]), 'sales'[product_key] = 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "mom" + assert translation.calculation == "previous_value" + assert translation.time_offset == "-1 month" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_keepfilters_sameperiodlastyear_with_additional_filter(): + expr = _parse_expression( + "CALCULATE([Total Sales], KEEPFILTERS(SAMEPERIODLASTYEAR('sales'[order_date])), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_keepfilters_dateadd_with_additional_filter(): + expr = _parse_expression( + "CALCULATE([Total Sales], KEEPFILTERS(DATEADD('sales'[order_date], -1, YEAR)), 'sales'[product_key] = 1)" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "time_comparison" + assert translation.base_metric == "sales.Total Sales" + assert translation.comparison_type == "yoy" + assert translation.calculation == "previous_value" + assert translation.time_offset == "1 year" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_summarizecolumns_table(): + expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], \"Revenue\", SUM('sales'[amount]))") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert ( + translation.sql + == "SELECT sales.product_key, SUM(sales.amount) AS Revenue FROM sales GROUP BY sales.product_key" + ) + + +def test_translate_summarize_table(): + expr = _parse_expression("SUMMARIZE('sales', 'sales'[product_key], \"Revenue\", SUM('sales'[amount]))") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert ( + translation.sql + == "SELECT sales.product_key, SUM(sales.amount) AS Revenue FROM sales GROUP BY sales.product_key" + ) + + +def test_translate_summarize_wrapped_row_group_by_bracket_alias(): + expr = _parse_expression('SUMMARIZE(ROW("x", 1, "y", 2), [x])') + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={}, + measure_names_by_table={}, + ) + + assert translation.sql == "SELECT x FROM (SELECT 1 AS x, 2 AS y) AS t GROUP BY x" + + +def test_translate_summarize_wrapped_multitable_row_group_by_bracket_alias(): + expr = _parse_expression("SUMMARIZE(ROW(\"x\", 'sales'[amount], \"d\", 'date'[date_key]), [x])") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert ( + "SELECT x FROM (SELECT amount AS x, date.date_key AS d FROM sales CROSS JOIN date) AS t GROUP BY x" + in translation.sql + ) + assert "sales" in translation.required_models + assert "date" in translation.required_models + + +def test_translate_summarize_wrapped_row_group_by_identifier_alias(): + expr = _parse_expression('SUMMARIZE(ROW("x", 1, "y", 2), x)') + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={}, + measure_names_by_table={}, + ) + + assert translation.sql == "SELECT x FROM (SELECT 1 AS x, 2 AS y) AS t GROUP BY x" + + +def test_translate_summarizecolumns_rollupgroup_table(): + expr = _parse_expression("SUMMARIZECOLUMNS(ROLLUPGROUP('sales'[product_key]), \"Rows\", COUNTROWS('sales'))") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" + + +def test_translate_summarizecolumns_rollupaddissubtotal_table(): + expr = _parse_expression( + "SUMMARIZECOLUMNS(ROLLUPADDISSUBTOTAL('sales'[product_key], \"is_subtotal\"), \"Rows\", COUNTROWS('sales'))" + ) + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" + + +def test_translate_summarizecolumns_all_filter_table_arg(): + expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], ALL('sales'), \"Rows\", COUNTROWS('sales'))") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" + + +def test_translate_summarizecolumns_values_filter_table_arg(): + expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], VALUES('sales'), \"Rows\", COUNTROWS('sales'))") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" + + +def test_translate_summarizecolumns_rollupissubtotal_table(): + expr = _parse_expression("SUMMARIZECOLUMNS(ROLLUPISSUBTOTAL('sales'[product_key]), \"Rows\", COUNTROWS('sales'))") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" + + +def test_translate_summarize_rollup_table(): + expr = _parse_expression("SUMMARIZE('sales', ROLLUP('sales'[product_key]), \"Rows\", COUNTROWS('sales'))") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" + + +def test_translate_selectcolumns_table_keeps_base_columns_in_scope(): + expr = _parse_expression("SELECTCOLUMNS('sales', \"Amount\", 'sales'[amount])") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT amount AS Amount FROM sales" + + +def test_translate_selectcolumns_table_with_cross_table_expression_joins_related_table(): + expr = _parse_expression("SELECTCOLUMNS('sales', \"Weight\", 'products'[weight])") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key", "weight": "weight"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql + == "SELECT products.weight AS Weight FROM sales LEFT JOIN products ON sales.product_key = products.product_key" + ) + assert "products" in translation.required_models + + +def test_translate_addcolumns_table_with_cross_table_expression_joins_related_table(): + expr = _parse_expression("ADDCOLUMNS('sales', \"Weight\", 'products'[weight])") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key", "weight": "weight"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql + == "SELECT sales.*, products.weight AS Weight FROM sales LEFT JOIN products ON sales.product_key = products.product_key" + ) + assert "products" in translation.required_models + + +def test_translate_selectcolumns_wrapped_base_with_cross_table_expression_joins_related_table(): + expr = _parse_expression("SELECTCOLUMNS(FILTER('sales', 'sales'[amount] > 0), \"Category\", 'products'[category])") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql == "SELECT products.category AS Category FROM (SELECT * FROM sales WHERE (amount > 0)) AS t " + "LEFT JOIN products ON t.product_key = products.product_key" + ) + assert "products" in translation.required_models + + +def test_translate_addcolumns_wrapped_base_with_cross_table_expression_cross_joins_when_unrelated(): + expr = _parse_expression("ADDCOLUMNS(FILTER('sales', 'sales'[amount] > 0), \"Rate\", 'tax'[rate])") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "tax": {"rate": "rate"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert ( + translation.sql + == "SELECT t.*, tax.rate AS Rate FROM (SELECT * FROM sales WHERE (amount > 0)) AS t CROSS JOIN tax" + ) + assert "tax" in translation.required_models + + +def test_translate_topn_base_table_with_cross_table_order_by_joins_related_table(): + expr = _parse_expression("TOPN(2, 'sales', 'products'[weight], DESC)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key", "weight": "weight"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql == "SELECT sales.* FROM sales LEFT JOIN products ON sales.product_key = products.product_key " + "ORDER BY products.weight DESC LIMIT 2" + ) + assert "products" in translation.required_models + + +def test_translate_topn_accepts_scalar_count_expression(): + expr = _parse_expression("TOPN(1 + 1, 'sales', 'sales'[amount], DESC)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={"sales": set()}, + ) + + assert "ORDER BY amount DESC" in translation.sql + assert "LIMIT CAST(((1 + 1)) AS BIGINT)" in translation.sql + + +def test_translate_topnskip_base_table_with_cross_table_order_by_cross_joins_when_unrelated(): + expr = _parse_expression("TOPNSKIP(2, 1, 'sales', 'tax'[rate], ASC)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "tax": {"rate": "rate"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert translation.sql == "SELECT sales.* FROM sales CROSS JOIN tax ORDER BY tax.rate ASC LIMIT 2 OFFSET 1" + assert "tax" in translation.required_models + + +def test_translate_topnskip_accepts_scalar_count_and_skip_expressions(): + expr = _parse_expression("TOPNSKIP(3 - 1, 5 / 2, 'sales', 'sales'[amount], DESC)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={"sales": set()}, + ) + + assert "ORDER BY amount DESC" in translation.sql + assert "LIMIT CAST(((3 - 1)) AS BIGINT)" in translation.sql + assert "OFFSET CAST(((5 / 2)) AS BIGINT)" in translation.sql + + +def test_translate_topn_wrapped_base_with_cross_table_order_by_joins_related_table(): + expr = _parse_expression("TOPN(2, FILTER('sales', 'sales'[amount] > 0), 'products'[weight], DESC)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key", "weight": "weight"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql + == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 0)) AS t LEFT JOIN products ON t.product_key = products.product_key " + "ORDER BY products.weight DESC LIMIT 2" + ) + assert "products" in translation.required_models + + +def test_translate_topnperlevel_base_table_with_cross_table_group_order_cross_joins_when_unrelated(): + expr = _parse_expression("TOPNPERLEVEL(1, 'tax'[region], 'sales', 'tax'[rate], DESC)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "tax": {"region": "region", "rate": "rate"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert ( + translation.sql + == "SELECT * EXCLUDE (__topnperlevel_rank) FROM (SELECT sales.*, RANK() OVER (PARTITION BY tax.region " + "ORDER BY tax.rate DESC) AS __topnperlevel_rank FROM sales CROSS JOIN tax) AS q " + "WHERE __topnperlevel_rank <= 1" + ) + assert "tax" in translation.required_models + + +def test_translate_topnperlevel_wrapped_base_with_cross_table_group_order_joins_related_table(): + expr = _parse_expression( + "TOPNPERLEVEL(1, 'products'[category], FILTER('sales', 'sales'[amount] > 0), 'products'[weight], DESC)" + ) + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category", "weight": "weight"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql + == "SELECT * EXCLUDE (__topnperlevel_rank) FROM (SELECT t.*, RANK() OVER (PARTITION BY products.category " + "ORDER BY products.weight DESC) AS __topnperlevel_rank FROM (SELECT * FROM sales WHERE (amount > 0)) AS t " + "LEFT JOIN products ON t.product_key = products.product_key) AS q WHERE __topnperlevel_rank <= 1" + ) + assert "products" in translation.required_models + + +def test_translate_filter_table_keeps_base_columns_in_scope(): + expr = _parse_expression("FILTER('sales', 'sales'[amount] > 100)") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT * FROM sales WHERE (amount > 100)" + + +def test_translate_filter_table_with_cross_table_predicate_joins_related_table(): + expr = _parse_expression("FILTER('sales', 'products'[weight] > 0)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"weight": "weight", "product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql == "SELECT sales.* FROM sales LEFT JOIN products ON sales.product_key = products.product_key " + "WHERE (products.weight > 0)" + ) + assert "products" in translation.required_models + + +def test_translate_filter_table_with_cross_table_predicate_cross_joins_when_unrelated(): + expr = _parse_expression("FILTER('sales', 'tax'[rate] > 0)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "tax": {"rate": "rate"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert translation.sql == "SELECT sales.* FROM sales CROSS JOIN tax WHERE (tax.rate > 0)" + assert "tax" in translation.required_models + + +def test_translate_filter_wrapped_base_with_cross_table_predicate_joins_related_table(): + expr = _parse_expression("FILTER(FILTER('sales', 'sales'[amount] > 0), 'products'[category] = \"Clothing\")") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql + == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 0)) AS t LEFT JOIN products ON t.product_key = products.product_key " + "WHERE (products.category = 'Clothing')" + ) + assert "products" in translation.required_models + + +def test_translate_filter_wrapped_base_with_cross_table_predicate_cross_joins_when_unrelated(): + expr = _parse_expression("FILTER(FILTER('sales', 'sales'[amount] > 0), 'tax'[rate] > 0)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "tax": {"rate": "rate"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert ( + translation.sql + == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 0)) AS t CROSS JOIN tax WHERE (tax.rate > 0)" + ) + assert "tax" in translation.required_models + + +def test_translate_calculatetable_table(): + expr = _parse_expression("CALCULATETABLE('sales', 'sales'[product_key] = 1)") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT * FROM sales WHERE (product_key = 1)" + + +def test_translate_calculatetable_base_table_with_cross_table_filter_joins_related_table(): + expr = _parse_expression("CALCULATETABLE('sales', 'products'[category] = \"Clothing\")") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql == "SELECT sales.* FROM sales LEFT JOIN products ON sales.product_key = products.product_key " + "WHERE (products.category = 'Clothing')" + ) + assert "products" in translation.required_models + + +def test_translate_calculatetable_base_table_with_cross_table_table_ref_filter_candidate(): + expr = _parse_expression("CALCULATETABLE('sales', 'date')") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert translation.sql == "SELECT * FROM sales" + assert "date" in translation.required_models + + +def test_translate_calculatetable_base_table_with_cross_table_filter_cross_joins_when_unrelated(): + expr = _parse_expression("CALCULATETABLE('sales', 'tax'[rate] > 0)") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "tax": {"rate": "rate"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert translation.sql == "SELECT sales.* FROM sales CROSS JOIN tax WHERE (tax.rate > 0)" + assert "tax" in translation.required_models + + +def test_translate_calculatetable_base_table_with_datesinperiod_filter(): + expr = _parse_expression("CALCULATETABLE('sales', DATESINPERIOD('sales'[order_date], \"2024-12-31\", -3, MONTH))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={"sales": {"order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + ) + + assert ( + translation.sql + == "SELECT * FROM sales WHERE (order_date > ('2024-12-31' + INTERVAL '-3 month') AND order_date <= '2024-12-31')" + ) + + +def test_translate_calculatetable_base_table_with_datesytd_filter(): + expr = _parse_expression("CALCULATETABLE('sales', DATESYTD('sales'[order_date]))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={"sales": {"order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + ) + + assert ( + translation.sql == "SELECT * FROM sales WHERE " + "(order_date >= DATE_TRUNC('year', (SELECT MAX(order_date) FROM sales)) " + "AND order_date <= (SELECT MAX(order_date) FROM sales))" + ) + + +def test_translate_calculatetable_base_table_with_cross_table_datesinperiod_filter_cross_joins_when_unrelated(): + expr = _parse_expression("CALCULATETABLE('sales', DATESINPERIOD('date'[date_key], \"2024-12-31\", -3, MONTH))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert ( + translation.sql + == "SELECT sales.* FROM sales CROSS JOIN date WHERE (date.date_key > ('2024-12-31' + INTERVAL '-3 month') AND date.date_key <= '2024-12-31')" + ) + assert "date" in translation.required_models + + +def test_translate_calculatetable_base_table_with_cross_table_datesqtd_filter_cross_joins_when_unrelated(): + expr = _parse_expression("CALCULATETABLE('sales', DATESQTD('date'[date_key]))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert ( + translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " + "(date.date_key >= DATE_TRUNC('quarter', (SELECT MAX(date.date_key) FROM date)) " + "AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" + ) + assert "date" in translation.required_models + + +def test_translate_calculatetable_base_table_with_cross_table_dateadd_filter_cross_joins_when_unrelated(): + expr = _parse_expression("CALCULATETABLE('sales', DATEADD('date'[date_key], -1, YEAR))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert ( + translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " + "(date.date_key > ((SELECT MAX(date.date_key) FROM date) + INTERVAL '-1 year') AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" + ) + assert "date" in translation.required_models + + +def test_translate_calculatetable_base_table_with_cross_table_sameperiodlastyear_filter_cross_joins_when_unrelated(): + expr = _parse_expression("CALCULATETABLE('sales', SAMEPERIODLASTYEAR('date'[date_key]))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert ( + translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " + "(date.date_key > ((SELECT MAX(date.date_key) FROM date) + INTERVAL '-1 year') AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" + ) + assert "date" in translation.required_models + + +def test_translate_calculatetable_base_table_with_cross_table_nextmonth_filter_cross_joins_when_unrelated(): + expr = _parse_expression("CALCULATETABLE('sales', NEXTMONTH('date'[date_key]))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert ( + translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " + "(date.date_key >= (SELECT MAX(date.date_key) FROM date) AND date.date_key < ((SELECT MAX(date.date_key) FROM date) + INTERVAL '1 month'))" + ) + assert "date" in translation.required_models + + +def test_translate_calculatetable_wrapped_base_keeps_columns_in_scope(): + expr = _parse_expression("CALCULATETABLE(FILTER('sales', 'sales'[amount] > 100), 'sales'[amount] > 200)") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT * FROM (SELECT * FROM sales WHERE (amount > 100)) AS t WHERE (amount > 200)" + + +def test_translate_calculatetable_wrapped_base_with_cross_table_filter_joins_related_table(): + expr = _parse_expression( + "CALCULATETABLE(FILTER('sales', 'sales'[amount] > 100), 'products'[category] = \"Clothing\")" + ) + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql + == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 100)) AS t LEFT JOIN products ON t.product_key = products.product_key " + "WHERE (products.category = 'Clothing')" + ) + assert "products" in translation.required_models + + +def test_translate_calculatetable_nested_replaces_same_column_filter(): + expr = _parse_expression( + "CALCULATETABLE(CALCULATETABLE('sales', 'sales'[product_key] = 1), 'sales'[product_key] = 2)" + ) + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT * FROM sales WHERE (product_key = 2)" + + +def test_translate_calculatetable_nested_keepfilters_preserves_inner_filter(): + expr = _parse_expression( + "CALCULATETABLE(CALCULATETABLE('sales', 'sales'[product_key] = 1), KEEPFILTERS('sales'[product_key] = 2))" + ) + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT * FROM sales WHERE (product_key = 1) AND (product_key = 2)" + + +def test_translate_calculatetable_nested_removefilters_clears_inner_filter(): + expr = _parse_expression( + "CALCULATETABLE(CALCULATETABLE('sales', 'sales'[product_key] = 1), REMOVEFILTERS('sales'[product_key]))" + ) + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT * FROM sales" + + +def test_translate_countrows_calculatetable_filters(): + expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', 'sales'[product_key] = 1))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_countrows_calculatetable_propagates_relationship_overrides(): + expr = _parse_expression( + "COUNTROWS(CALCULATETABLE('sales', USERELATIONSHIP('sales'[product_key], 'products'[product_key])))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "count" + assert translation.filters == [] + assert len(translation.relationship_overrides) == 1 + assert translation.relationship_overrides[0].join_type is None + assert translation.relationship_overrides[0].direction is None + + +def test_translate_countrows_calculatetable_datesinperiod_cross_table_filter(): + expr = _parse_expression( + "COUNTROWS(CALCULATETABLE('sales', DATESINPERIOD('date'[date_key], \"2024-12-31\", -3, MONTH)))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == [ + "(date.date_key > ('2024-12-31' + INTERVAL '-3 month') AND date.date_key <= '2024-12-31')" + ] + assert "date" in translation.required_models + + +def test_translate_countrows_calculatetable_datesmtd_cross_table_filter(): + expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', DATESMTD('date'[date_key])))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == [ + "(date.date_key >= DATE_TRUNC('month', (SELECT MAX(date.date_key) FROM date)) " + "AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" + ] + assert "date" in translation.required_models + + +def test_translate_countrows_calculatetable_dateadd_cross_table_filter(): + expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', DATEADD('date'[date_key], -1, YEAR)))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == [ + "(date.date_key > ((SELECT MAX(date.date_key) FROM date) + INTERVAL '-1 year') " + "AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" + ] + assert "date" in translation.required_models + + +def test_translate_countrows_calculatetable_cross_table_table_ref_filter_candidate(): + expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', 'date'))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_calculate_filter_argument_propagates_relationship_overrides(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "FILTER(" + "CALCULATETABLE('sales', CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)), " + "'sales'[product_key] = 1" + ")" + ")" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key = 1)"] + assert len(translation.relationship_overrides) == 1 + assert translation.relationship_overrides[0].join_type == "inner" + assert translation.relationship_overrides[0].direction == "Both" + + +def test_translate_sumx_calculatetable_propagates_relationship_overrides(): + expr = _parse_expression( + "SUMX(" + "CALCULATETABLE('sales', CROSSFILTER('sales'[product_key], 'products'[product_key], NONE)), " + "'sales'[amount]" + ")" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert len(translation.relationship_overrides) == 1 + assert translation.relationship_overrides[0].join_type == "left" + assert translation.relationship_overrides[0].direction == "None" + + +def test_translate_values_table_column(): + expr = _parse_expression("VALUES('sales'[product_key])") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT DISTINCT product_key FROM sales" + + +def test_translate_distinct_table_ref(): + expr = _parse_expression("DISTINCT('sales')") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT DISTINCT * FROM (SELECT * FROM sales) AS t" + + +def test_translate_countrows_values_as_count_distinct(): + expr = _parse_expression("COUNTROWS(VALUES('sales'[product_key]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count_distinct" + assert translation.sql == "product_key" + assert translation.filters == [] + + +def test_translate_countrows_distinct_as_count_distinct(): + expr = _parse_expression("COUNTROWS(DISTINCT('sales'[product_key]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count_distinct" + assert translation.sql == "product_key" + assert translation.filters == [] + + +def test_translate_countrows_values_cross_table_column_as_count_distinct(): + expr = _parse_expression("COUNTROWS(VALUES('date'[date_key]))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count_distinct" + assert translation.sql == "date.date_key" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_countrows_filters_cross_table_column_as_count_distinct(): + expr = _parse_expression("COUNTROWS(FILTERS('date'[date_key]))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count_distinct" + assert translation.sql == "date.date_key" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_countrows_cross_table_identifier_table(): + expr = _parse_expression("COUNTROWS('date')") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_countrows_values_filtered_cross_table_propagates_filters(): + expr = _parse_expression("COUNTROWS(VALUES(FILTER('date', 'date'[date_key] = 1)))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == ["(date.date_key = 1)"] + assert "date" in translation.required_models + + +def test_translate_countrows_identifier_table(): + expr = _parse_expression("COUNTROWS(sales)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == [] + + +def test_translate_counta_aggregate(): + expr = _parse_expression("COUNTA('sales'[product_key])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql == "product_key" + assert translation.filters == [] + + +def test_translate_averagea_aggregate(): + expr = _parse_expression("AVERAGEA('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "avg" + assert translation.sql == "amount" + assert translation.filters == [] + + +def test_translate_mina_aggregate(): + expr = _parse_expression("MINA('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "min" + assert translation.sql == "amount" + assert translation.filters == [] + + +def test_translate_countblank_aggregate(): + expr = _parse_expression("COUNTBLANK('sales'[product_key])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql == "CASE WHEN product_key IS NULL THEN 1 END" + assert translation.filters == [] + + +def test_translate_distinctcountnoblank_aggregate(): + expr = _parse_expression("DISTINCTCOUNTNOBLANK('sales'[product_key])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count_distinct" + assert translation.sql == "product_key" + assert translation.filters == [] + + +def test_translate_approximatedistinctcount_aggregate(): + expr = _parse_expression("APPROXIMATEDISTINCTCOUNT('sales'[product_key])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count_distinct" + assert translation.sql == "product_key" + assert translation.filters == [] + + +def test_translate_selectedvalue_with_alternate(): + expr = _parse_expression("SELECTEDVALUE('sales'[product_key], -1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CASE WHEN COUNT(DISTINCT product_key) = 1 THEN MIN(product_key) ELSE -1 END" + + +def test_translate_selectedvalue_rejects_more_than_two_arguments(): + expr = _parse_expression("SELECTEDVALUE('sales'[product_key], -1, 0)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises( + DaxTranslationError, match="SELECTEDVALUE supports at most column and alternate_result arguments" + ): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_hasonevalue(): + expr = _parse_expression("HASONEVALUE('sales'[product_key])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(COUNT(DISTINCT product_key) = 1)" + + +def test_translate_hasonevalue_rejects_more_than_one_argument(): + expr = _parse_expression("HASONEVALUE('sales'[product_key], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="HASONEVALUE/HASONEFILTER supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_firstnonblank(): + expr = _parse_expression("FIRSTNONBLANK('sales'[product_key], 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "MIN(CASE WHEN amount IS NOT NULL THEN product_key ELSE NULL END)" + + +def test_translate_firstnonblank_rejects_more_than_two_arguments(): + expr = _parse_expression("FIRSTNONBLANK('sales'[product_key], 'sales'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises( + DaxTranslationError, match="FIRSTNONBLANK/LASTNONBLANK supports exactly column and expression arguments" + ): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_lastnonblank(): + expr = _parse_expression("LASTNONBLANK('sales'[product_key], 'sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "MAX(CASE WHEN amount IS NOT NULL THEN product_key ELSE NULL END)" + + +def test_translate_firstdate(): + expr = _parse_expression("FIRSTDATE('sales'[order_date])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "MIN(order_date)" + + +def test_translate_endofmonth(): + expr = _parse_expression("ENDOFMONTH('sales'[order_date])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "MIN(DATE_TRUNC('month', order_date) + INTERVAL '1 month' - INTERVAL '1 day')" + + +def test_translate_containsstring(): + expr = _parse_expression("CONTAINSSTRING('sales'[status], \"open\")") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "(POSITION(LOWER('open') IN LOWER(status)) > 0)" + + +def test_translate_containsstringexact(): + expr = _parse_expression("CONTAINSSTRINGEXACT('sales'[status], \"Open\")") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "(POSITION('Open' IN status) > 0)" + + +def test_translate_containsrow_table_constructor(): + expr = _parse_expression("CONTAINSROW({1, 2, 3}, 'sales'[product_key])") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"product_key": "product_key", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert ( + translation.sql + == "EXISTS (SELECT 1 FROM (SELECT 1 AS value1 UNION ALL SELECT 2 AS value1 UNION ALL SELECT 3 AS value1) " + "AS t(c1) WHERE t.c1 IS NOT DISTINCT FROM product_key)" + ) + + +def test_translate_containsrow_rejects_non_table_first_argument(): + expr = _parse_expression("CONTAINSROW(1, 1)") + with pytest.raises(DaxTranslationError, match="CONTAINSROW requires a table expression as first argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"product_key": "product_key", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_containsrow_rejects_value_count_mismatch(): + expr = _parse_expression( + "CONTAINSROW(SELECTCOLUMNS('sales', \"k\", 'sales'[product_key], \"q\", 'sales'[quantity]), 'sales'[product_key])" + ) + with pytest.raises(DaxTranslationError, match="CONTAINSROW value argument count must match table column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"product_key": "product_key", "quantity": "quantity", "order_date": "order_date"} + }, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_containsrow_rejects_non_inferable_table_width(): + expr = _parse_expression("CONTAINSROW('sales', 1)") + with pytest.raises(DaxTranslationError, match="CONTAINSROW requires an inferable table column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": set()}, + ) + + +def test_translate_containsstring_rejects_more_than_two_arguments(): + expr = _parse_expression('CONTAINSSTRING(\'sales\'[status], "open", "extra")') + with pytest.raises(DaxTranslationError, match="CONTAINSSTRING supports exactly two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_containsstringexact_rejects_more_than_two_arguments(): + expr = _parse_expression('CONTAINSSTRINGEXACT(\'sales\'[status], "Open", "extra")') + with pytest.raises(DaxTranslationError, match="CONTAINSSTRINGEXACT supports exactly two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_len(): + expr = _parse_expression("LEN('sales'[status])") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "LENGTH(status)" + + +def test_translate_len_rejects_more_than_one_argument(): + expr = _parse_expression("LEN('sales'[status], 1)") + with pytest.raises(DaxTranslationError, match="LEN supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_left(): + expr = _parse_expression("LEFT('sales'[status], 3)") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "SUBSTRING(status, 1, GREATEST(3, 0))" + + +def test_translate_left_default_num_chars(): + expr = _parse_expression("LEFT('sales'[status])") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "SUBSTRING(status, 1, GREATEST(1, 0))" + + +def test_translate_left_rejects_more_than_two_arguments(): + expr = _parse_expression("LEFT('sales'[status], 1, 2)") + with pytest.raises(DaxTranslationError, match="LEFT supports at most text and num_chars arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_right(): + expr = _parse_expression("RIGHT('sales'[status], 3)") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert ( + translation.sql == "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(status, GREATEST(LENGTH(status) - 3 + 1, 1), 3) END" + ) + + +def test_translate_right_rejects_more_than_two_arguments(): + expr = _parse_expression("RIGHT('sales'[status], 1, 2)") + with pytest.raises(DaxTranslationError, match="RIGHT supports at most text and num_chars arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_replace(): + expr = _parse_expression("REPLACE('sales'[status], 2, 2, \"xx\")") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert ( + translation.sql + == "(CASE WHEN GREATEST(2, 1) <= 1 THEN '' ELSE SUBSTRING(status, 1, GREATEST(2, 1) - 1) END || 'xx' || " + "SUBSTRING(status, GREATEST(2, 1) + GREATEST(2, 0)))" + ) + + +def test_translate_replace_requires_four_arguments(): + expr = _parse_expression("REPLACE('sales'[status], 2, 2)") + with pytest.raises( + DaxTranslationError, match="REPLACE requires old_text, start_num, num_chars, and new_text arguments" + ): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_substitute(): + expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy")') + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "REPLACE(status, 'ab', 'xy')" + + +def test_translate_substitute_instance_num(): + expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy", 1)') + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert ( + translation.sql + == "CASE WHEN 'ab' = '' THEN status WHEN (INSTR(status, 'ab')) = 0 THEN status ELSE SUBSTR(status, 1, " + "(INSTR(status, 'ab')) - 1) || 'xy' || SUBSTR(status, (INSTR(status, 'ab')) + LENGTH('ab')) END" + ) + + +def test_translate_substitute_instance_num_accepts_scalar_expression(): + expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy", 1 + 0)') + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert "STRING_SPLIT(status, 'ab')" in translation.sql + assert "CAST(((1 + 0)) AS BIGINT)" in translation.sql + + +def test_translate_substitute_instance_num_requires_positive_integer(): + expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy", 0)') + with pytest.raises(DaxTranslationError, match="SUBSTITUTE instance_num must be >= 1"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_rept(): + expr = _parse_expression("REPT('sales'[status], 3)") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "REPEAT(status, GREATEST(CAST(FLOOR(3) AS BIGINT), 0))" + + +def test_translate_rept_requires_two_arguments(): + expr = _parse_expression("REPT('sales'[status])") + with pytest.raises(DaxTranslationError, match="REPT requires text and number_times arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_rept_rejects_more_than_two_arguments(): + expr = _parse_expression("REPT('sales'[status], 2, 3)") + with pytest.raises(DaxTranslationError, match="REPT supports exactly two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_trim(): + expr = _parse_expression("TRIM('sales'[status])") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "TRIM(REGEXP_REPLACE(CAST(status AS VARCHAR), ' +', ' ', 'g'))" + + +def test_translate_trim_requires_argument(): + expr = _parse_expression("TRIM()") + with pytest.raises(DaxTranslationError, match="TRIM requires an argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_trim_rejects_more_than_one_argument(): + expr = _parse_expression("TRIM('sales'[status], 'x')") + with pytest.raises(DaxTranslationError, match="TRIM supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_mid(): + expr = _parse_expression("MID('sales'[status], 2, 3)") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(status, GREATEST(2, 1), 3) END" + + +def test_translate_mid_requires_three_arguments(): + expr = _parse_expression("MID('sales'[status], 2)") + with pytest.raises(DaxTranslationError, match="MID requires text, start_num, and num_chars arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_mid_rejects_more_than_three_arguments(): + expr = _parse_expression("MID('sales'[status], 2, 3, 4)") + with pytest.raises(DaxTranslationError, match="MID requires text, start_num, and num_chars arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_exact(): + expr = _parse_expression("EXACT('sales'[status], \"Open\")") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.type == "derived" + assert translation.sql == "(status = 'Open')" + + +def test_translate_exact_rejects_more_than_two_arguments(): + expr = _parse_expression('EXACT(\'sales\'[status], "Open", "Closed")') + with pytest.raises(DaxTranslationError, match="EXACT supports exactly two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_date_ctor(): + expr = _parse_expression("DATE(2024, 2, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "MAKE_DATE(2024, 2, 1)" + + +def test_translate_date_ctor_rejects_more_than_three_arguments(): + expr = _parse_expression("DATE(2024, 2, 1, 5)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="DATE requires year, month, and day arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_time_ctor(): + expr = _parse_expression("TIME(12, 30, 0)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "MAKE_TIME(12, 30, 0)" + + +def test_translate_time_ctor_rejects_more_than_three_arguments(): + expr = _parse_expression("TIME(12, 30, 0, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TIME requires hour, minute, and second arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_datevalue(): + expr = _parse_expression('DATEVALUE("2024-02-01")') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CAST('2024-02-01' AS DATE)" + + +def test_translate_datevalue_rejects_more_than_one_argument(): + expr = _parse_expression('DATEVALUE("2024-02-01", "extra")') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="DATEVALUE supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_timevalue(): + expr = _parse_expression('TIMEVALUE("12:34:56")') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CAST('12:34:56' AS TIME)" + + +def test_translate_timevalue_rejects_more_than_one_argument(): + expr = _parse_expression('TIMEVALUE("12:34:56", "extra")') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TIMEVALUE supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_edate(): + expr = _parse_expression('EDATE("2024-02-01", 2)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(CAST('2024-02-01' AS DATE) + (2) * INTERVAL '1 month')" + + +def test_translate_edate_rejects_more_than_two_arguments(): + expr = _parse_expression('EDATE("2024-02-01", 2, 1)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="EDATE requires start date and month offset arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_eomonth(): + expr = _parse_expression('EOMONTH("2024-02-01", 0)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == ( + "(DATE_TRUNC('month', (CAST('2024-02-01' AS DATE) + (0) * INTERVAL '1 month')) + INTERVAL '1 month' - " + "INTERVAL '1 day')" + ) + + +def test_translate_eomonth_rejects_more_than_two_arguments(): + expr = _parse_expression('EOMONTH("2024-02-01", 0, 1)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="EOMONTH requires start date and month offset arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_datediff(): + expr = _parse_expression('DATEDIFF("2024-01-01", "2024-02-01", MONTH)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "DATE_DIFF('month', CAST('2024-01-01' AS DATE), CAST('2024-02-01' AS DATE))" + + +def test_translate_datediff_rejects_more_than_three_arguments(): + expr = _parse_expression('DATEDIFF("2024-01-01", "2024-02-01", MONTH, DAY)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="DATEDIFF requires start date, end date, and interval arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_weekday(): + expr = _parse_expression('WEEKDAY("2024-02-01", 2)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(((EXTRACT(DOW FROM CAST('2024-02-01' AS DATE)) + 6) % 7) + 1)" + + +def test_translate_weekday_rejects_more_than_two_arguments(): + expr = _parse_expression('WEEKDAY("2024-02-01", 2, 3)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="WEEKDAY supports at most date and return_type arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_weekday_return_type_11(): + expr = _parse_expression('WEEKDAY("2024-02-01", 11)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(((EXTRACT(DOW FROM CAST('2024-02-01' AS DATE)) - 1 + 7) % 7) + 1)" + + +def test_translate_weeknum(): + expr = _parse_expression('WEEKNUM("2024-02-01", 2)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(CAST(STRFTIME(CAST('2024-02-01' AS DATE), '%W') AS INTEGER) + 1)" + + +def test_translate_weeknum_rejects_more_than_two_arguments(): + expr = _parse_expression('WEEKNUM("2024-02-01", 2, 3)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="WEEKNUM supports at most date and return_type arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_weeknum_return_type_21(): + expr = _parse_expression('WEEKNUM("2024-02-01", 21)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CAST(STRFTIME(CAST('2024-02-01' AS DATE), '%V') AS INTEGER)" + + +def test_translate_year_rejects_more_than_one_argument(): + expr = _parse_expression('YEAR("2024-02-01", 2)') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="YEAR supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_isinscope_defaults_false_without_group_context(): + expr = _parse_expression("ISINSCOPE('sales'[product_key])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "FALSE" + + +def test_translate_isinscope_rejects_more_than_one_argument(): + expr = _parse_expression("ISINSCOPE('sales'[product_key], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="ISINSCOPE supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_isfiltered_defaults_false_without_filter_context(): + expr = _parse_expression("ISFILTERED('sales'[product_key])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "FALSE" + + +def test_translate_isfiltered_rejects_more_than_one_argument(): + expr = _parse_expression("ISFILTERED('sales'[product_key], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="ISFILTERED/ISCROSSFILTERED supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_today(): + expr = _parse_expression("TODAY()") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CURRENT_DATE" + + +def test_translate_today_rejects_arguments(): + expr = _parse_expression("TODAY(1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TODAY does not take arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_now_rejects_arguments(): + expr = _parse_expression("NOW(1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="NOW does not take arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_utcnow_rejects_arguments(): + expr = _parse_expression("UTCNOW(1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="UTCNOW does not take arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_utctoday_rejects_arguments(): + expr = _parse_expression("UTCTODAY(1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="UTCTODAY does not take arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_rand(): + expr = _parse_expression("RAND()") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "RANDOM()" + + +def test_translate_rand_rejects_arguments(): + expr = _parse_expression("RAND(1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="RAND does not take arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_randbetween(): + expr = _parse_expression("RANDBETWEEN(1, 10)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CAST(FLOOR(RANDOM() * ((10) - (1) + 1) + (1)) AS BIGINT)" + + +def test_translate_randbetween_rejects_more_than_two_arguments(): + expr = _parse_expression("RANDBETWEEN(1, 10, 20)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="RANDBETWEEN supports exactly two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_format(): + expr = _parse_expression("FORMAT('sales'[amount], \"0.00\")") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CAST(amount AS VARCHAR)" + + +def test_translate_format_requires_format_string_argument(): + expr = _parse_expression("FORMAT('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="FORMAT requires value and format_string arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_format_rejects_more_than_three_arguments(): + expr = _parse_expression('FORMAT(\'sales\'[amount], "0.00", "en-US", "extra")') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="FORMAT supports at most value, format_string, and locale arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_iferror(): + expr = _parse_expression("IFERROR('sales'[amount], 0)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CASE WHEN amount IS NULL THEN 0 ELSE amount END" + + +def test_translate_iferror_rejects_more_than_two_arguments(): + expr = _parse_expression("IFERROR('sales'[amount], 0, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="IFERROR supports exactly two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_coalesce(): + expr = _parse_expression("COALESCE('sales'[amount], 0)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "COALESCE(amount, 0)" + + +def test_translate_coalesce_requires_at_least_two_arguments(): + expr = _parse_expression("COALESCE('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="COALESCE requires at least two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_switch_boolean_predicate_form(): + expr = _parse_expression("SWITCH(TRUE(), 'sales'[amount] > 0, 1, 0)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CASE WHEN (amount > 0) THEN 1 ELSE 0 END" + + +def test_translate_switch_requires_expression_and_value_result_pair(): + expr = _parse_expression("SWITCH('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + expr_missing_result = _parse_expression("SWITCH('sales'[amount], 1)") + with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): + translate_dax_metric( + expr_missing_result, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_divide_rejects_more_than_three_arguments(): + expr = _parse_expression("DIVIDE('sales'[amount], 2, 0, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises( + DaxTranslationError, + match="DIVIDE supports at most numerator, denominator, and alternate result arguments", + ): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_and_or_reject_more_than_two_arguments(): + and_expr = _parse_expression("AND(TRUE(), FALSE(), TRUE())") + or_expr = _parse_expression("OR(TRUE(), FALSE(), TRUE())") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + + with pytest.raises(DaxTranslationError, match="AND supports exactly two arguments"): + translate_dax_metric( + and_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + with pytest.raises(DaxTranslationError, match="OR supports exactly two arguments"): + translate_dax_metric( + or_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_roundup(): + expr = _parse_expression("ROUNDUP('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert ( + translation.sql == "CASE WHEN 2 >= 0 THEN SIGN(amount) * CEIL(ABS(amount) * POWER(10, 2)) / POWER(10, 2) ELSE " + "SIGN(amount) * CEIL(ABS(amount) / POWER(10, -(2))) * POWER(10, -(2)) END" + ) + + +def test_translate_round(): + expr = _parse_expression("ROUND('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert ( + translation.sql + == "CASE WHEN 2 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 2) + 0.5) / POWER(10, 2) ELSE " + "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(2)) + 0.5) * POWER(10, -(2)) END" + ) + + +def test_translate_round_requires_two_arguments(): + expr = _parse_expression("ROUND('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="ROUND requires number and num_digits arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_round_rejects_more_than_two_arguments(): + expr = _parse_expression("ROUND('sales'[amount], 2, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="ROUND requires number and num_digits arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_rounddown(): + expr = _parse_expression("ROUNDDOWN('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert ( + translation.sql == "CASE WHEN 2 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 2)) / POWER(10, 2) ELSE " + "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(2))) * POWER(10, -(2)) END" + ) + + +def test_translate_int(): + expr = _parse_expression("INT('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "FLOOR(amount)" + + +def test_translate_int_rejects_more_than_one_argument(): + expr = _parse_expression("INT('sales'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="INT supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_trunc(): + expr = _parse_expression("TRUNC('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert ( + translation.sql == "CASE WHEN 2 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 2)) / POWER(10, 2) ELSE " + "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(2))) * POWER(10, -(2)) END" + ) + + +def test_translate_trunc_defaults_num_digits_to_zero(): + expr = _parse_expression("TRUNC('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert ( + translation.sql == "CASE WHEN 0 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 0)) / POWER(10, 0) ELSE " + "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(0))) * POWER(10, -(0)) END" + ) + + +def test_translate_trunc_rejects_more_than_two_arguments(): + expr = _parse_expression("TRUNC('sales'[amount], 2, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TRUNC supports at most number and num_digits arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_ceiling_with_significance(): + expr = _parse_expression("CEILING('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(CEIL(amount / 2) * 2)" + + +def test_translate_floor_with_significance(): + expr = _parse_expression("FLOOR('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(FLOOR(amount / 2) * 2)" + + +def test_translate_ceiling_floor_reject_more_than_two_arguments(): + ceiling_expr = _parse_expression("CEILING('sales'[amount], 2, 1)") + floor_expr = _parse_expression("FLOOR('sales'[amount], 2, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + + with pytest.raises(DaxTranslationError, match="CEILING supports at most number and significance arguments"): + translate_dax_metric( + ceiling_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + with pytest.raises(DaxTranslationError, match="FLOOR supports at most number and significance arguments"): + translate_dax_metric( + floor_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_mround(): + expr = _parse_expression("MROUND('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert ( + translation.sql == "CASE WHEN 2 = 0 THEN 0 ELSE SIGN(amount) * FLOOR((ABS(amount) / ABS(2)) + 0.5) * ABS(2) END" + ) + + +def test_translate_mround_requires_two_arguments(): + expr = _parse_expression("MROUND('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="MROUND requires number and multiple arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_mround_rejects_more_than_two_arguments(): + expr = _parse_expression("MROUND('sales'[amount], 2, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="MROUND requires number and multiple arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_min_aggregate_single_argument(): + expr = _parse_expression("MIN('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type is None + assert translation.agg == "min" + assert translation.sql == "amount" + + +def test_translate_max_aggregate_single_argument(): + expr = _parse_expression("MAX('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type is None + assert translation.agg == "max" + assert translation.sql == "amount" + + +def test_translate_min_scalar_two_arguments(): + expr = _parse_expression("MIN('sales'[amount], 10)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "LEAST(amount, 10)" + + +def test_translate_max_scalar_two_arguments(): + expr = _parse_expression("MAX('sales'[amount], 10)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "GREATEST(amount, 10)" + + +def test_translate_min_max_reject_more_than_two_arguments(): + min_expr = _parse_expression("MIN('sales'[amount], 10, 20)") + max_expr = _parse_expression("MAX('sales'[amount], 10, 20)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + + with pytest.raises(DaxTranslationError, match="MIN supports one aggregate argument or two scalar arguments"): + translate_dax_metric( + min_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + with pytest.raises(DaxTranslationError, match="MAX supports one aggregate argument or two scalar arguments"): + translate_dax_metric( + max_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_sum_rejects_more_than_one_argument(): + expr = _parse_expression("SUM('sales'[amount], 10)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + + with pytest.raises(DaxTranslationError, match="SUM supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_countrows_rejects_more_than_one_argument(): + expr = _parse_expression("COUNTROWS('sales', 'sales')") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + + with pytest.raises(DaxTranslationError, match="COUNTROWS supports at most one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_abs(): + expr = _parse_expression("ABS('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "ABS(amount)" + + +def test_translate_abs_requires_one_argument(): + expr = _parse_expression("ABS('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="ABS requires exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_mod(): + expr = _parse_expression("MOD('sales'[amount], 3)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "MOD(amount, 3)" + + +def test_translate_mod_requires_two_arguments(): + expr = _parse_expression("MOD('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="MOD requires number and divisor arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_power(): + expr = _parse_expression("POWER('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "POWER(amount, 2)" + + +def test_translate_power_requires_two_arguments(): + expr = _parse_expression("POWER('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="POWER requires number and exponent arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_sqrt(): + expr = _parse_expression("SQRT('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "SQRT(amount)" + + +def test_translate_exp(): + expr = _parse_expression("EXP('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "EXP(amount)" + + +def test_translate_ln(): + expr = _parse_expression("LN('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "LN(amount)" + + +def test_translate_log10(): + expr = _parse_expression("LOG10('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "LOG10(amount)" + + +def test_translate_log_default_base(): + expr = _parse_expression("LOG('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "LOG10(amount)" + + +def test_translate_log_with_base(): + expr = _parse_expression("LOG('sales'[amount], 2)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "(LN(amount) / LN(2))" + + +def test_translate_log_rejects_more_than_two_arguments(): + expr = _parse_expression("LOG('sales'[amount], 2, 3)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="LOG supports at most number and base arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_pi(): + expr = _parse_expression("PI()") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "PI()" + + +def test_translate_pi_rejects_arguments(): + expr = _parse_expression("PI(1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="PI does not take arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_nameof(): + expr = _parse_expression("NAMEOF('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "'sales[amount]'" + + +def test_translate_nameof_rejects_more_than_one_argument(): + expr = _parse_expression("NAMEOF('sales'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="NAMEOF supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_convert(): + expr = _parse_expression("CONVERT('sales'[amount], STRING)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "CAST(amount AS VARCHAR)" + + +def test_translate_convert_rejects_more_than_two_arguments(): + expr = _parse_expression("CONVERT('sales'[amount], STRING, 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="CONVERT supports exactly value and datatype arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_related_rejects_more_than_one_argument(): + expr = _parse_expression("RELATED('other'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="RELATED supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_value_rejects_more_than_one_argument(): + expr = _parse_expression("VALUE('sales'[amount_text], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="VALUE supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_concatenate_rejects_more_than_two_arguments(): + expr = _parse_expression('CONCATENATE(\'sales\'[sku], "-", "x")') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="CONCATENATE supports exactly two arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_concatenatex(): + expr = _parse_expression("CONCATENATEX('sales', 'sales'[product_key], \"-\", 'sales'[product_key], DESC)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert "STRING_AGG(CAST(product_key AS VARCHAR), CAST('-' AS VARCHAR) ORDER BY product_key DESC)" in translation.sql + assert "FROM sales" in translation.sql + + +def test_translate_concatenatex_wrapped_table_expression(): + expr = _parse_expression("CONCATENATEX(FILTER('sales', 'sales'[amount] > 10), 'sales'[product_key], \",\")") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert "STRING_AGG(CAST(t.product_key AS VARCHAR), CAST(',' AS VARCHAR))" in translation.sql + assert "FROM (SELECT * FROM sales WHERE (amount > 10)) AS t" in translation.sql + + +def test_translate_concatenatex_cross_table_expression_tracks_required_model(): + expr = _parse_expression("CONCATENATEX('sales', 'products'[category], \",\")") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert "STRING_AGG(CAST(products.category AS VARCHAR), CAST(',' AS VARCHAR))" in translation.sql + assert "FROM sales LEFT JOIN products ON sales.product_key = products.product_key" in translation.sql + assert "products" in translation.required_models + + +def test_translate_concatenatex_requires_table_and_expression_arguments(): + expr = _parse_expression("CONCATENATEX('sales')") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="CONCATENATEX requires table and expression arguments"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_if_rejects_more_than_three_arguments(): + expr = _parse_expression("IF(TRUE(), 1, 2, 3)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises( + DaxTranslationError, match="IF supports at most condition, value_if_true, and value_if_false arguments" + ): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_evaluateandlog(): + expr = _parse_expression("EVALUATEANDLOG('sales'[amount])") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "amount" + + +def test_translate_evaluateandlog_rejects_more_than_one_argument(): + expr = _parse_expression("EVALUATEANDLOG('sales'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="EVALUATEANDLOG supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_ignore_rejects_more_than_one_argument(): + expr = _parse_expression("IGNORE('sales'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="IGNORE supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_nonvisual_rejects_more_than_one_argument(): + expr = _parse_expression("NONVISUAL('sales'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="NONVISUAL supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_upper_lower_reject_more_than_one_argument(): + upper_expr = _parse_expression('UPPER("abc", "x")') + lower_expr = _parse_expression('LOWER("ABC", "x")') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + + with pytest.raises(DaxTranslationError, match="UPPER supports exactly one argument"): + translate_dax_metric( + upper_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + with pytest.raises(DaxTranslationError, match="LOWER supports exactly one argument"): + translate_dax_metric( + lower_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_isblank_rejects_more_than_one_argument(): + isblank_expr = _parse_expression("ISBLANK('sales'[amount], 1)") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + + with pytest.raises(DaxTranslationError, match="ISBLANK supports exactly one argument"): + translate_dax_metric( + isblank_expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_isempty_table_expression(): + expr = _parse_expression("ISEMPTY(FILTER('sales', 'sales'[amount] > 0))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.type == "derived" + assert translation.sql == "NOT EXISTS (SELECT 1 FROM (SELECT * FROM sales WHERE (amount > 0)) AS t)" + + +def test_translate_isempty_rejects_more_than_one_argument(): + expr = _parse_expression("ISEMPTY('sales', 'sales')") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="ISEMPTY supports exactly one argument"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_values_identifier_table_ref(): + expr = _parse_expression("VALUES(sales)") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT DISTINCT * FROM sales" + + +@pytest.mark.parametrize( + ("expr_sql", "expected_fragment"), + [ + ("UNION('sales', 'tax')", "UNION ALL"), + ("INTERSECT('sales', 'tax')", "INTERSECT ALL"), + ("EXCEPT('sales', 'tax')", "EXCEPT ALL"), + ("CROSSJOIN('sales', 'tax')", "CROSS JOIN"), + ("NATURALINNERJOIN('sales', 'tax')", "NATURAL INNER JOIN"), + ("NATURALLEFTOUTERJOIN('sales', 'tax')", "NATURAL LEFT JOIN"), + ], +) +def test_translate_table_set_operations_allow_multi_table_refs_when_model_is_none( + expr_sql: str, expected_fragment: str +): + expr = _parse_expression(expr_sql) + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "tax": {"rate": "rate", "date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert expected_fragment in translation.sql + assert "SELECT * FROM sales" in translation.sql + assert "SELECT * FROM tax" in translation.sql + + +def test_translate_generate_allows_cross_table_right_table_when_model_is_none(): + expr = _parse_expression("GENERATE('sales', FILTER('tax', 'tax'[rate] > 0))") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount", "date_key": "date_key"}, + "tax": {"rate": "rate", "date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert "CROSS JOIN LATERAL" in translation.sql + assert "FROM (SELECT * FROM sales) AS l" in translation.sql + assert "FROM tax" in translation.sql + assert "rate > 0" in translation.sql + + +def test_translate_row_table_allows_multi_table_refs_when_model_is_none(): + expr = _parse_expression("ROW(\"sales_amount\", 'sales'[amount], \"tax_rate\", 'tax'[rate])") + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table={ + "sales": {"amount": "amount"}, + "tax": {"rate": "rate"}, + }, + measure_names_by_table={"sales": set(), "tax": set()}, + ) + + assert "SELECT amount AS sales_amount, tax.rate AS tax_rate FROM sales CROSS JOIN tax" in translation.sql + assert "sales" in translation.required_models + assert "tax" in translation.required_models + + +def test_translate_calendar_table(): + expr = _parse_expression('CALENDAR("2024-01-01", "2024-01-03")') + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={"sales": set()}, + ) + + assert translation.sql == ( + "SELECT date_value AS Date FROM generate_series(CAST('2024-01-01' AS DATE), " + "CAST('2024-01-03' AS DATE), INTERVAL '1 day') AS gs(date_value)" + ) + + +def test_translate_calendar_table_cross_table_aggregate_bounds(): + expr = _parse_expression("CALENDAR(MIN('sales'[order_date]), MAX('date'[date_key]))") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"order_date": "order_date"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert "CAST((SELECT MIN(order_date) FROM sales) AS DATE)" in translation.sql + assert "CAST((SELECT MAX(date.date_key) FROM date) AS DATE)" in translation.sql + assert "date" in translation.required_models + + +def test_translate_generateseries_table(): + expr = _parse_expression("GENERATESERIES(1, 5, 2)") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={"sales": set()}, + ) + + assert translation.sql == "SELECT value FROM generate_series(1, 5, 2) AS gs(value)" + + +def test_translate_generateseries_table_cross_table_aggregate_bounds(): + expr = _parse_expression("GENERATESERIES(MIN('sales'[amount]), MAX('date'[date_key]), 1)") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + ) + + assert ( + "generate_series((SELECT MIN(amount) FROM sales), (SELECT MAX(date.date_key) FROM date), 1)" in translation.sql + ) + assert "date" in translation.required_models + + +def test_translate_firstdate_table(): + expr = _parse_expression("FIRSTDATE('sales'[order_date])") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT MIN(order_date) AS value1 FROM sales" + + +def test_translate_startofyear_table(): + expr = _parse_expression("STARTOFYEAR('sales'[order_date])") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT MIN(DATE_TRUNC('year', order_date)) AS value1 FROM sales" + + +def test_translate_filters_table_column(): + expr = _parse_expression("FILTERS('sales'[product_key])") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT DISTINCT product_key FROM sales" + + +def test_translate_all_table_ref(): + expr = _parse_expression("ALL('sales')") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT * FROM sales" + + +def test_translate_all_column_ref(): + expr = _parse_expression("ALL('sales'[product_key])") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT DISTINCT product_key FROM sales" + + +def test_translate_treatas_table(): + expr = _parse_expression("TREATAS({1, 2}, 'sales'[product_key])") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + assert translation.sql == "SELECT DISTINCT product_key AS product_key FROM sales WHERE (product_key IN (1, 2))" + + +def test_translate_treatas_table_cross_table_target_joins_related_table(): + expr = _parse_expression("TREATAS({\"A\"}, 'products'[category])") + translation = translate_dax_table( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ], + ) + + assert ( + translation.sql + == "SELECT DISTINCT products.category AS category FROM products WHERE (products.category IN ('A'))" + ) + assert "products" in translation.required_models + + +def test_translate_treatas_table_rejects_non_column_target_arguments(): + expr = _parse_expression("TREATAS({1, 2}, 1)") + column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS target arguments must reference table columns"): + translate_dax_table( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + ) + + +def test_translate_treatas_filter_rejects_table_expression_width_mismatch(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "TREATAS(SELECTCOLUMNS('sales', \"k\", 'sales'[product_key], \"q\", 'sales'[quantity]), 'sales'[product_key])" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_table_ref_width_mismatch_when_known(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS('sales', 'sales'[product_key]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_filter_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), TREATAS(FILTER('sales', 'sales'[amount] > 10), 'sales'[product_key]))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_calculatetable_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), TREATAS(CALCULATETABLE('sales', 'sales'[amount] > 10), 'sales'[product_key]))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_values_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "TREATAS(FILTER(VALUES('sales'[product_key]), 'sales'[product_key] > 1), 'sales'[product_key], 'sales'[quantity])" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_union_wrapper_width_mismatch(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS(UNION('sales', 'sales'), 'sales'[product_key]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_crossjoin_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), TREATAS(CROSSJOIN('sales', 'sales'), 'sales'[product_key]))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_generate_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), TREATAS(GENERATE('sales', 'sales'), 'sales'[product_key]))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_naturalinnerjoin_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), TREATAS(NATURALINNERJOIN('sales', 'sales'), 'sales'[product_key]))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_naturalleftouterjoin_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), TREATAS(NATURALLEFTOUTERJOIN('sales', 'sales'), 'sales'[product_key]))" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_renamecolumns_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "TREATAS(RENAMECOLUMNS('sales', 'sales'[product_key], \"k\"), 'sales'[product_key], 'sales'[quantity])" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_removecolumns_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "TREATAS(REMOVECOLUMNS('sales', 'sales'[amount], 'sales'[quantity], 'sales'[order_date]), " + "'sales'[product_key], 'sales'[quantity])" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_treatas_filter_rejects_keepcolumns_wrapper_width_mismatch(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "TREATAS(KEEPCOLUMNS('sales', 'sales'[product_key], 'sales'[quantity]), 'sales'[product_key])" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + +def test_translate_countrows_filters_as_count_distinct(): + expr = _parse_expression("COUNTROWS(FILTERS('sales'[product_key]))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count_distinct" + assert translation.sql == "product_key" + assert translation.filters == [] + + +def test_translate_countrows_values_filtered_table_propagates_filters(): + expr = _parse_expression("COUNTROWS(VALUES(FILTER('sales', 'sales'[product_key] = 1)))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_countrows_distinct_filtered_table_propagates_filters(): + expr = _parse_expression("COUNTROWS(DISTINCT(FILTER('sales', 'sales'[product_key] = 1)))") + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_countrows_datesbetween_table_propagates_filters(): + expr = _parse_expression('COUNTROWS(DATESBETWEEN(\'sales\'[order_date], "2024-01-01", "2024-12-31"))') + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "count" + assert translation.sql is None + assert translation.filters == ["(order_date >= '2024-01-01' AND order_date <= '2024-12-31')"] + + +def test_translate_summarizecolumns_multiple_tables_cross_join(): + expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], 'products'[category])") + column_sql_by_table = { + "sales": {"product_key": "product_key"}, + "products": {"category": "category"}, + } + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table={}, + ) + + assert translation.sql == ( + "SELECT sales.product_key, products.category FROM sales CROSS JOIN products " + "GROUP BY sales.product_key, products.category" + ) + + +def test_translate_dax_query_warns_when_unrelated_tables_are_cross_joined(): + query = _parse_query("EVALUATE SUMMARIZECOLUMNS('sales'[product_key], 'products'[category])") + translation = translate_dax_query( + query, + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"category": "category"}, + }, + measure_names_by_table={}, + ) + + assert translation.warnings == [] + assert len(translation.evaluates) == 1 + assert translation.evaluates[0].warnings == [ + { + "code": "dax_unrelated_cross_join", + "context": "query", + "base_table": "sales", + "table": "products", + "message": ( + "DAX query cross joins unrelated table 'products' with 'sales' because no relationship path is defined" + ), + } + ] + + +def test_translate_summarizecolumns_multiple_tables_with_relationships(): + expr = _parse_expression("SUMMARIZECOLUMNS('products'[category], \"Revenue\", SUM('sales'[amount]))") + column_sql_by_table = { + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key", "category": "category"}, + } + edges = [ + RelationshipEdge( + from_table="sales", + from_column="product_key", + to_table="products", + to_column="product_key", + ) + ] + translation = translate_dax_table( + expr, + model_name=None, + column_sql_by_table=column_sql_by_table, + measure_names_by_table={}, + relationship_edges=edges, + ) + + assert "LEFT JOIN" in translation.sql + assert "products.product_key" in translation.sql + assert "sales.product_key" in translation.sql + assert "GROUP BY products.category" in translation.sql + + +def test_translate_calculate_cross_table_filter(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'products'[category] = \"A\")") + column_sql_by_table = { + "sales": {"amount": "amount"}, + "products": {"category": "category"}, + } + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "products": {"category"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert any("products" in clause for clause in translation.filters) + assert "products" in translation.required_models + + +def test_translate_calculate_table_ref_filter_candidate_same_table(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'sales')") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert translation.required_models == set() + + +def test_translate_calculate_identifier_table_filter_candidate_cross_table(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), date)") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_calculate_values_cross_table_filter_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), VALUES('date'))") + column_sql_by_table = { + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + } + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_calculate_filter_cross_table_base_table_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), FILTER('date', 'date'[date_key] = 20240101))") + column_sql_by_table = { + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + } + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(date.date_key = 20240101)"] + assert "date" in translation.required_models + + +def test_translate_calculate_filter_cross_table_derived_constructor_alias_predicate_uses_exists(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), FILTER({('sales'[amount], 'date'[date_key])}, [value1] > 0))" + ) + column_sql_by_table = { + "sales": {"amount": "amount", "date_key": "date_key"}, + "date": {"date_key": "date_key"}, + } + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + relationship_edges=[ + RelationshipEdge( + from_table="sales", + from_column="date_key", + to_table="date", + to_column="date_key", + ) + ], + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert len(translation.filters) == 1 + assert translation.filters[0].startswith("EXISTS (SELECT 1 FROM (SELECT * FROM (SELECT") + assert "AS value2 FROM sales" in translation.filters[0] + assert "AS t WHERE (value1 > 0)" in translation.filters[0] + assert "date" in translation.required_models + + +def test_translate_calculate_filter_cross_table_derived_selectcolumns_alias_predicate_rewrites_directly(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), FILTER(SELECTCOLUMNS('date', \"x\", 'date'[date_key]), [x] > 20240101))" + ) + column_sql_by_table = { + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + } + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert len(translation.filters) == 1 + assert translation.filters == ["(date.date_key > 20240101)"] + assert "date" in translation.required_models + + +def test_translate_calculate_filter_same_table_derived_addcolumns_alias_predicate_rewrites_directly(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), FILTER(ADDCOLUMNS('sales', \"x\", 'sales'[amount]), [x] > 0))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(amount > 0)"] + + +def test_translate_calculate_filters_cross_table_filter_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), FILTERS('date'[date_key]))") + column_sql_by_table = { + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + } + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_calculate_distinct_cross_table_filter_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DISTINCT('date'[date_key]))") + column_sql_by_table = { + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + } + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_calculate_treatas_filter(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS({1, 2}, 'sales'[product_key]))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key IN (1, 2))"] + + +def test_translate_calculate_values_table_filter_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), VALUES('sales'))") + translation = translate_dax_metric( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert translation.required_models == {"sales"} + + +def test_translate_calculate_values_column_filter_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), VALUES('sales'[product_key]))") + translation = translate_dax_metric( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert translation.required_models == {"sales"} + + +def test_translate_calculate_filters_column_filter_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), FILTERS('sales'[product_key]))") + translation = translate_dax_metric( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert translation.required_models == {"sales"} + + +def test_translate_calculate_distinct_column_filter_candidate(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DISTINCT('sales'[product_key]))") + translation = translate_dax_metric( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert translation.required_models == {"sales"} + + +def test_translate_calculate_datesbetween_filter(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), DATESBETWEEN('sales'[order_date], \"2024-01-01\", \"2024-12-31\"))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(order_date >= '2024-01-01' AND order_date <= '2024-12-31')"] + + +def test_translate_calculate_datesbetween_filter_open_ended_with_blank(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), DATESBETWEEN('sales'[order_date], BLANK, \"2024-12-31\"))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "order_date": "order_date"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(order_date <= '2024-12-31')"] + + +def test_translate_calculate_datesbetween_filter_cross_table_start_bound(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), DATESBETWEEN('sales'[order_date], 'date'[date_key], BLANK))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "order_date": "order_date"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(order_date >= date.date_key)"] + assert "date" in translation.required_models + + +def test_translate_calculate_treatas_cross_table_filter(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS({\"A\"}, 'products'[category]))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "products": {"category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "products": {"category"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(products.category IN ('A'))"] + assert "products" in translation.required_models + + +def test_translate_calculate_nonvisual_treatas_filter(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), NONVISUAL(TREATAS({1}, 'sales'[product_key])))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(product_key IN (1))"] + + +def test_translate_calculate_removefilters_applies_to_inherited_filters(): + expr = _parse_expression( + "CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), REMOVEFILTERS('sales'[product_key]))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == [] + + +def test_translate_calculate_removefilters_cross_table_clears_inherited_cross_table_filter(): + expr = _parse_expression( + "CALCULATE(CALCULATE(SUM('sales'[amount]), FILTER('date', 'date'[date_key] = 1)), REMOVEFILTERS('date'))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_calculate_all_clears_inherited_filters(): + expr = _parse_expression("CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), ALL())") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == [] + + +def test_translate_calculate_all_cross_table_table_arg_tracks_required_model(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), ALL('date'))") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + measure_names_by_table={"sales": set(), "date": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == [] + assert "date" in translation.required_models + + +def test_translate_calculate_allnoblankrow_clears_inherited_filters(): + expr = _parse_expression("CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), ALLNOBLANKROW())") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == [] + + +def test_translate_calculate_allexcept_keeps_selected_columns(): + expr = _parse_expression( + "CALCULATE(" + "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, 'sales'[quantity] = 2), " + "ALLEXCEPT('sales', 'sales'[product_key])" + ")" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key", "quantity": "quantity"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_allselected_column_removes_targeted_filters(): + expr = _parse_expression( + "CALCULATE(" + "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, 'sales'[quantity] = 2), " + "ALLSELECTED('sales'[quantity])" + ")" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key", "quantity": "quantity"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_allcrossfiltered_table_removes_table_filters(): + expr = _parse_expression( + "CALCULATE(" + "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, 'sales'[quantity] = 2), " + "ALLCROSSFILTERED('sales')" + ")" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key", "quantity": "quantity"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == [] + + +def test_translate_calculate_allselected_no_args_keeps_inherited_filters(): + expr = _parse_expression("CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), ALLSELECTED())") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == ["(product_key = 1)"] + + +def test_translate_calculate_allselected_no_args_clears_current_scope_filters(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, ALLSELECTED())") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == [] + + +def test_translate_calculate_allexcept_rejects_cross_table_columns(): + expr = _parse_expression("CALCULATE(SUM('sales'[amount]), ALLEXCEPT('sales', 'products'[category]))") + with pytest.raises(DaxTranslationError): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "products": {"category": "category"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_calculate_replaces_inherited_filter_on_same_column(): + expr = _parse_expression( + "CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), 'sales'[product_key] = 2)" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == ["(product_key = 2)"] + + +def test_translate_calculate_keepfilters_preserves_inherited_filter_on_same_column(): + expr = _parse_expression( + "CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), KEEPFILTERS('sales'[product_key] = 2))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, + measure_names_by_table={"sales": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.filters == ["(product_key = 1)", "(product_key = 2)"] + + +def test_translate_calculate_accumulates_relationship_overrides(): + expr = _parse_expression( + "CALCULATE(" + "CALCULATE(SUM('sales'[amount]), USERELATIONSHIP('sales'[product_key], 'products'[product_key])), " + "CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)" + ")" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert len(translation.relationship_overrides) == 2 + assert translation.relationship_overrides[0].join_type is None + assert translation.relationship_overrides[1].join_type == "inner" + assert translation.relationship_overrides[1].direction == "Both" + + +def test_translate_calculate_crossfilter_none_direction(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), CROSSFILTER('sales'[product_key], 'products'[product_key], NONE))" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert len(translation.relationship_overrides) == 1 + assert translation.relationship_overrides[0].join_type == "left" + assert translation.relationship_overrides[0].direction == "None" + + +def test_translate_calculate_crossfilter_oneway_leftfiltersright_direction(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "CROSSFILTER('products'[product_key], 'sales'[product_key], ONEWAY_LEFTFILTERSRIGHT)" + ")" + ) + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert len(translation.relationship_overrides) == 1 + assert translation.relationship_overrides[0].join_type is None + assert translation.relationship_overrides[0].direction == "OneWay_LeftFiltersRight" + + +def test_translate_calculate_crossfilter_invalid_direction_error(): + expr = _parse_expression( + "CALCULATE(SUM('sales'[amount]), CROSSFILTER('sales'[product_key], 'products'[product_key], SIDEWAYS))" + ) + with pytest.raises(DaxTranslationError): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + measure_names_by_table={"sales": set(), "products": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_cross_table_metric_tracks_required_model(): + expr = _parse_expression("SUM('other'[amount])") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount", "order_date": "order_date"}, + "other": {"amount": "amount"}, + }, + measure_names_by_table={"sales": {"Total Sales"}, "other": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + assert translation.agg == "sum" + assert translation.sql == "other.amount" + assert "other" in translation.required_models + + +def test_translate_model_scoped_derived_cross_table_metric_tracks_required_model(): + expr = _parse_expression("'sales'[amount] + 'other'[amount]") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "other": {"amount": "amount"}, + }, + measure_names_by_table={"sales": set(), "other": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={}, + ) + + assert translation.type == "derived" + assert translation.sql == "(amount + other.amount)" + assert "other" in translation.required_models + + +def test_translate_model_scoped_var_derived_cross_table_metric_tracks_required_model(): + expr = _parse_expression("VAR x = 'other'[amount] RETURN x + 'sales'[amount]") + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "other": {"amount": "amount"}, + }, + measure_names_by_table={"sales": set(), "other": set()}, + measure_aggs_by_table={}, + time_dimensions_by_table={}, + ) + + assert translation.type == "derived" + assert translation.sql == "(other.amount + amount)" + assert "other" in translation.required_models + + +def test_time_intelligence_requires_known_time_dimension(): + expr = _parse_expression("CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[event_ts]))") + with pytest.raises(DaxTranslationError): + translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount", "event_ts": "event_ts"}}, + measure_names_by_table={"sales": {"Total Sales"}}, + measure_aggs_by_table={}, + time_dimensions_by_table={"sales": {"order_date"}}, + ) + + +def test_translate_table_rejects_scalar_expression_type(): + expr = _parse_expression("1") + with pytest.raises(DaxTranslationError, match="Unsupported table expression type 'Number'"): + translate_dax_table( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={}, + ) + + +def test_translate_table_rejects_unknown_identifier_as_table(): + expr = _parse_expression("UnknownTable") + with pytest.raises(DaxTranslationError, match="Unknown table identifier 'UnknownTable'"): + translate_dax_table( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={}, + ) + + +def test_translate_scalar_rejects_query_expression_node_type(): + expr = _parse_query("EVALUATE 'sales'") + with pytest.raises(DaxTranslationError, match="Unsupported DAX expression type 'Query'"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + ) + + +def test_translate_scalar_unknown_function_error_is_explicit(): + expr = _parse_expression("UNKNOWNFUNC(1, 2)") + with pytest.raises(DaxTranslationError, match="Unsupported scalar function 'UNKNOWNFUNC'"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + ) + + +def test_translate_scalar_calc_group_function_error_is_explicit(): + expr = _parse_expression("SELECTEDMEASURE()") + with pytest.raises(DaxTranslationError, match="SELECTEDMEASURE is only supported in calculation group expressions"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + ) + + +def test_translate_table_detailrows_function_error_is_explicit(): + expr = _parse_expression("DETAILROWS('sales')") + with pytest.raises(DaxTranslationError, match="DETAILROWS is only supported in model detail rows expressions"): + translate_dax_table( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={}, + ) + + +def test_translate_scalar_substitutewithindex_table_function_error_is_explicit(): + expr = _parse_expression("SUBSTITUTEWITHINDEX('sales', \"Idx\", 'sales'[amount], 'sales')") + with pytest.raises( + DaxTranslationError, match="SUBSTITUTEWITHINDEX returns a table and is not valid in scalar context" + ): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + ) + + +def test_translate_calculate_substitutewithindex_filter_propagates_underlying_filters(): + expr = _parse_expression( + "CALCULATE(" + "SUM('sales'[amount]), " + "SUBSTITUTEWITHINDEX(" + "FILTER('sales', 'sales'[amount] > 100), " + '"Idx", ' + "VALUES('sales'[product_key]), " + "'sales'[product_key]" + ")" + ")" + ) + column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() + translation = translate_dax_metric( + expr, + model_name="sales", + column_sql_by_table=column_sql_by_table, + measure_names_by_table=measure_names_by_table, + measure_aggs_by_table={}, + time_dimensions_by_table=time_dimensions_by_table, + ) + + assert translation.agg == "sum" + assert translation.sql == "amount" + assert translation.filters == ["(amount > 100)"] + + +def test_translate_table_calc_group_function_error_is_explicit(): + expr = _parse_expression("SELECTEDMEASURE()") + with pytest.raises(DaxTranslationError, match="SELECTEDMEASURE is only supported in calculation group expressions"): + translate_dax_table( + expr, + model_name=None, + column_sql_by_table={"sales": {"amount": "amount"}}, + measure_names_by_table={}, + ) + + +def test_translate_scalar_table_function_error_is_explicit(): + expr = _parse_expression("INTERSECT({1, 2}, {2, 3})") + with pytest.raises(DaxTranslationError, match="INTERSECT returns a table and is not valid in scalar context"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"amount": "amount"}}, + ) + + +def test_translate_scalar_calculate_filter_only_function_error_is_explicit(): + expr = _parse_expression("USERELATIONSHIP('sales'[product_key], 'products'[product_key])") + with pytest.raises(DaxTranslationError, match="USERELATIONSHIP is only valid in CALCULATE filter arguments"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + ) + + +def test_translate_scalar_crossfilter_calculate_filter_only_function_error_is_explicit(): + expr = _parse_expression("CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)") + with pytest.raises(DaxTranslationError, match="CROSSFILTER is only valid in CALCULATE filter arguments"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"product_key": "product_key"}, + "products": {"product_key": "product_key"}, + }, + ) + + +def test_translate_scalar_previousweek_table_function_error_is_explicit(): + expr = _parse_expression("PREVIOUSWEEK('date'[date_key])") + with pytest.raises(DaxTranslationError, match="PREVIOUSWEEK returns a table and is not valid in scalar context"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + time_dimensions_by_table={"date": {"date_key"}}, + ) + + +def test_translate_scalar_nextweek_table_function_error_is_explicit(): + expr = _parse_expression("NEXTWEEK('date'[date_key])") + with pytest.raises(DaxTranslationError, match="NEXTWEEK returns a table and is not valid in scalar context"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={ + "sales": {"amount": "amount"}, + "date": {"date_key": "date_key"}, + }, + time_dimensions_by_table={"date": {"date_key"}}, + ) + + +def test_translate_scalar_rollup_wrapper_table_function_error_is_explicit(): + expr = _parse_expression("ROLLUP('sales'[product_key])") + with pytest.raises(DaxTranslationError, match="ROLLUP returns a table and is not valid in scalar context"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"product_key": "product_key"}}, + ) + + +def test_translate_scalar_keepcolumns_table_function_error_is_explicit(): + expr = _parse_expression("KEEPCOLUMNS('sales', 'sales'[product_key])") + with pytest.raises(DaxTranslationError, match="KEEPCOLUMNS returns a table and is not valid in scalar context"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"product_key": "product_key"}}, + ) + + +@pytest.mark.parametrize( + ("expr_text", "func_name"), + [ + ("ROLLUPGROUP('sales'[product_key])", "ROLLUPGROUP"), + ("ROLLUPADDISSUBTOTAL(\"IsTotal\", 'sales'[product_key])", "ROLLUPADDISSUBTOTAL"), + ("ROLLUPISSUBTOTAL(\"IsTotal\", 'sales'[product_key])", "ROLLUPISSUBTOTAL"), + ], +) +def test_translate_scalar_rollup_wrapper_table_functions_error_is_explicit(expr_text: str, func_name: str): + expr = _parse_expression(expr_text) + with pytest.raises(DaxTranslationError, match=rf"{func_name} returns a table and is not valid in scalar context"): + translate_dax_scalar( + expr, + model_name="sales", + column_sql_by_table={"sales": {"product_key": "product_key"}}, + ) diff --git a/tests/fixtures/external_powerbi/SOURCES.md b/tests/fixtures/external_powerbi/SOURCES.md new file mode 100644 index 00000000..17feb229 --- /dev/null +++ b/tests/fixtures/external_powerbi/SOURCES.md @@ -0,0 +1,18 @@ +# External Power BI Fixture Sources + +This directory contains small, permissively licensed Power BI/TMDL/DAX fixtures used to exercise real exported syntax. + +Only source text needed by tests is copied: `.tmdl` files, one DAX text file, and upstream license files. PBIX binaries, report JSON, images, generated app code, and data files are intentionally excluded. + +Trailing whitespace was stripped from copied text files to keep patches clean; no semantic TMDL or DAX content was changed. + +| Fixture | Upstream | License | Commit | Copied paths | +| --- | --- | --- | --- | --- | +| `microsoft-analysis-services-sales` | | MIT | `61ee41607dfb0fa50378165fdb0fc03042c0ef17` | `pbidevmode/fabricps-pbip/SamplePBIP/Sales.SemanticModel/**/*.tmdl` | +| `microsoft-fabric-samples-bank-customer-churn` | | MIT | `6107067b0152392f87e10704b5c645d2c1123818` | `docs-samples/data-science/enrich-powerbi-report-with-machine-learning/Bank Customer Churn Analysis/Bank Customer Churn Analysis/Bank Customer Churn Analysis.SemanticModel/**/*.tmdl` | +| `pbi-tools-adventureworks-dw2020` | | MIT | `c47fefe4dc48df6461fc45b0442910b5b95f193d` | `pbix/Model/**/*.tmdl` | +| `pbip-lineage-explorer-sample` | | MIT | `ccced0cdaa58822eff76e4b0f17a5b4bc0678080` | `public/sample-pbip/**/*.tmdl` | +| `ruiromano-pbip-demo-agentic-model01` | | MIT | `2c573dfeb90a4d9983ebcbc340642a8126597605` | `.resources/pbip-sample/Model01.SemanticModel/**/*.tmdl` | +| `marfolger-powerbi-dax` | | MIT | `2773ab5713a800e7c6243f97995440112a93bda6` | `business_logic_DAX.txt` | + +Each fixture subdirectory contains `LICENSE.upstream` copied from the source repository. diff --git a/tests/fixtures/external_powerbi/marfolger-powerbi-dax/LICENSE.upstream b/tests/fixtures/external_powerbi/marfolger-powerbi-dax/LICENSE.upstream new file mode 100644 index 00000000..17b2dd34 --- /dev/null +++ b/tests/fixtures/external_powerbi/marfolger-powerbi-dax/LICENSE.upstream @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Marlym Alvarado Folger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/external_powerbi/marfolger-powerbi-dax/business_logic_DAX.txt b/tests/fixtures/external_powerbi/marfolger-powerbi-dax/business_logic_DAX.txt new file mode 100644 index 00000000..5c2a9e16 --- /dev/null +++ b/tests/fixtures/external_powerbi/marfolger-powerbi-dax/business_logic_DAX.txt @@ -0,0 +1,49 @@ +// ============================================================================== +// Power BI DAX Measures for Helsingin Soitinhuolto +// Purpose: Business Intelligence & Performance Tracking +// ============================================================================== + +// 1. DYNAMIC REVENUE METRICS +// Calculates Total Revenue including base price and dynamic add-ons +Total Revenue = +SUMX( + 'Orders', + 'Orders'[Base_Service_Price] + 'Orders'[Addon_Value] +) + +// 2. TIME INTELLIGENCE: Month-over-Month (MoM) Growth +// Demonstrates ability to compare current performance against historical data +Revenue MoM Growth % = +VAR CurrentMonthRev = [Total Revenue] +VAR PreviousMonthRev = CALCULATE( + [Total Revenue], + DATEADD('Calendar'[Date], -1, MONTH) +) +RETURN + DIVIDE(CurrentMonthRev - PreviousMonthRev, PreviousMonthRev, 0) + +// 3. OPERATIONAL KPI: Average Turnaround Time (TAT) +// Measures the efficiency of the workshop from pickup to delivery +Avg Turnaround Days = +AVERAGEX( + FILTER('Logistics', 'Logistics'[Delivery_Status] = "Delivered"), + DATEDIFF('Logistics'[Pickup_Date], 'Logistics'[Delivery_Date], DAY) +) + +// 4. CUSTOMER SEGMENTATION: High Value Client Flag +// A calculated column to identify clients spending above the 80th percentile +Is High Value Client = +IF( + 'Orders'[Total_Order_Value] > PERCENTILE.INC('Orders'[Total_Order_Value], 0.8), + "VIP", + "Standard" +) + +// 5. LOGISTICS EFFICIENCY: Pickup Utilization Rate +// Measures how many clients prefer the premium pickup service vs. drop-off +Pickup Preference % = +DIVIDE( + CALCULATE(COUNT('Orders'[ID]), 'Orders'[Logistics_Type] = "Pickup"), + COUNT('Orders'[ID]), + 0 +) \ No newline at end of file diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/LICENSE.upstream b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/LICENSE.upstream new file mode 100644 index 00000000..12eee491 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/LICENSE.upstream @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Microsoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/cultures/en-US.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/cultures/en-US.tmdl new file mode 100644 index 00000000..8e70a490 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/cultures/en-US.tmdl @@ -0,0 +1,12772 @@ +culture en-US + + linguisticMetadata = + { + "Version": "3.1.0", + "Language": "en-US", + "Entities": { + "calendar": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar" + } + }, + "State": "Generated", + "Terms": [ + { + "calendar": { + "State": "Generated" + } + }, + { + "almanac": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "datebook": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "agenda": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "logbook": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "diary": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "schedule": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "timetable": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "calendar.date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Date" + } + }, + "State": "Generated", + "Terms": [ + { + "date": { + "State": "Generated" + } + }, + { + "moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ], + "SemanticType": "Time" + }, + "calendar.day": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Day" + } + }, + "State": "Generated", + "Terms": [ + { + "day": { + "State": "Generated" + } + } + ] + }, + "calendar.week_day": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Week Day" + } + }, + "State": "Generated", + "Terms": [ + { + "week day": { + "State": "Generated" + } + } + ] + }, + "calendar.week": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Week" + } + }, + "State": "Generated", + "Terms": [ + { + "week": { + "State": "Generated" + } + } + ] + }, + "calendar.month": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Month" + } + }, + "State": "Generated", + "Terms": [ + { + "month": { + "State": "Generated" + } + }, + { + "mth": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + } + ] + }, + "calendar.quarter": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Quarter" + } + }, + "State": "Generated", + "Terms": [ + { + "quarter": { + "State": "Generated" + } + }, + { + "qtr": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.818 + } + } + ] + }, + "calendar.semester": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Semester" + } + }, + "State": "Generated", + "Terms": [ + { + "semester": { + "State": "Generated" + } + } + ] + }, + "calendar.year": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Year" + } + }, + "State": "Generated", + "Terms": [ + { + "year": { + "State": "Generated" + } + }, + { + "yr": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + } + ], + "SemanticType": "Time" + }, + "calendar.week1": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Week (Year)" + } + }, + "State": "Generated", + "Terms": [ + { + "week": { + "State": "Generated" + } + }, + { + "week (Year)": { + "State": "Generated" + } + }, + { + "week ( yr )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ], + "Units": [ + "year" + ] + }, + "calendar.month1": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Month (Year)" + } + }, + "State": "Generated", + "Terms": [ + { + "month": { + "State": "Generated" + } + }, + { + "month (Year)": { + "State": "Generated" + } + }, + { + "mth": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "mth (year)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + }, + { + "month ( yr )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ], + "Units": [ + "year" + ] + }, + "calendar.quarter1": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Quarter (Year)" + } + }, + "State": "Generated", + "Terms": [ + { + "quarter": { + "State": "Generated" + } + }, + { + "quarter (Year)": { + "State": "Generated" + } + }, + { + "qtr": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.818 + } + }, + { + "qtr (year)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.686 + } + }, + { + "quarter ( yr )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ], + "Units": [ + "year" + ] + }, + "calendar.semester1": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Semester (Year)" + } + }, + "State": "Generated", + "Terms": [ + { + "semester": { + "State": "Generated" + } + }, + { + "semester (Year)": { + "State": "Generated" + } + }, + { + "semester ( yr )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ], + "Units": [ + "year" + ] + }, + "calendar.week_year_id": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "WeekYearId" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "week year id": { + "State": "Generated" + } + }, + { + "WeekYearId": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "week year": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "year id": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "week year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "week year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "week year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "week yr": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "week year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "week yr id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + } + ] + }, + "calendar.month_year_id": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "MonthYearId" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "month year id": { + "State": "Generated" + } + }, + { + "MonthYearId": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "month year": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "year id": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "month year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "month year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "month year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "month yr": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "month year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "mth year id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + } + ] + }, + "calendar.quarter_year_id": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "QuarterYearId" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "quarter year id": { + "State": "Generated" + } + }, + { + "QuarterYearId": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "quarter year": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "year id": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "quarter year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "quarter year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "quarter year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "quarter yr": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "quarter year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "qtr year id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.677 + } + } + ] + }, + "calendar.semester_year_id": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "SemesterYearId" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "semester year id": { + "State": "Generated" + } + }, + { + "SemesterYearId": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "semester year": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "year id": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "semester year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "semester year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "semester year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "semester yr": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "year identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "semester year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "year credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "semester yr id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + } + ] + }, + "calendar.week_day_": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Week Day (#)" + } + }, + "State": "Generated", + "Terms": [ + { + "week day (#)": { + "State": "Generated" + } + }, + { + "week day": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "week day ( no )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.605 + } + }, + { + "week day ( num )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.605 + } + }, + { + "week day ( number )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.605 + } + } + ] + }, + "calendar.month_": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Month (#)" + } + }, + "State": "Generated", + "Terms": [ + { + "month (#)": { + "State": "Generated" + } + }, + { + "month": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "mth (#)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + }, + { + "month ( no )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "month ( num )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "month ( number )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + } + ] + }, + "calendar.day_relative": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Day (Relative)" + } + }, + "State": "Generated", + "Terms": [ + { + "day (relative)": { + "State": "Generated" + } + }, + { + "day": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "calendar.month_relative": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Month (Relative)" + } + }, + "State": "Generated", + "Terms": [ + { + "month (relative)": { + "State": "Generated" + } + }, + { + "month": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "mth (relative)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ] + }, + "calendar.year_relative": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Year (Relative)" + } + }, + "State": "Generated", + "Terms": [ + { + "year (relative)": { + "State": "Generated" + } + }, + { + "year": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "yr (relative)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ] + }, + "calendar.work_day": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Work Day" + } + }, + "State": "Generated", + "Terms": [ + { + "work day": { + "State": "Generated" + } + } + ] + }, + "calendar.date_id": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "DateId" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "date id": { + "State": "Generated" + } + }, + { + "DateId": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "date identification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "date identity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "date identifier": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "date credential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "moment id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "period id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + } + ] + }, + "calendar.month_long": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Month (Long)" + } + }, + "State": "Generated", + "Terms": [ + { + "month (long)": { + "State": "Generated" + } + }, + { + "month": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "mth (long)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ] + }, + "calendar.week_relative": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Week (Relative)" + } + }, + "State": "Generated", + "Terms": [ + { + "week (relative)": { + "State": "Generated" + } + }, + { + "week": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "calendar.week_start_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Week Start Date" + } + }, + "State": "Generated", + "Terms": [ + { + "week start date": { + "State": "Generated" + } + }, + { + "start date": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "week start moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "start moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "week start period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "start period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "week commencement date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.615 + } + }, + { + "week inception date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.615 + } + }, + { + "week kickoff date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.615 + } + }, + { + "commencement date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "inception date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "kickoff date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + } + ], + "SemanticType": "Time" + }, + "calendar.week_end_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Week End Date" + } + }, + "State": "Generated", + "Terms": [ + { + "week end date": { + "State": "Generated" + } + }, + { + "end date": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "week end moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "end moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "week end period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "end period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "week culmination date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.615 + } + }, + { + "week completion date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + }, + { + "week conclusion date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + }, + { + "week expiration date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + }, + { + "culmination date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "completion date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + } + ], + "SemanticType": "Time" + }, + "sale": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales" + } + }, + "State": "Generated", + "Terms": [ + { + "sale": { + "State": "Generated" + } + }, + { + "auction": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "transaction": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "deal": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "trade": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "vending": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "retailing": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "selling": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + } + ] + }, + "sale.quantity": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Quantity" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "quantity": { + "State": "Generated" + } + }, + { + "extent": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "magnitude": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "size": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "capacity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "mass": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "sale.currency_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Currency Code" + } + }, + "State": "Generated", + "Terms": [ + { + "currency code": { + "State": "Generated" + } + }, + { + "currency id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "currency key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ] + }, + "sale.unit_cost": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Unit Cost" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "unit cost": { + "State": "Generated" + } + }, + { + "cost": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "module cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "element cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "entity cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "group cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "component cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "constituent cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "item cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "part cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "section cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "unit charge": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "sale.net_price": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Net Price" + } + }, + "State": "Generated", + "Terms": [ + { + "net price": { + "State": "Generated" + } + }, + { + "price": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "net value": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net worth": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net fee": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net charge": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net amount": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net bill": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net rate": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net expense": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "net outlay": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "sale.sales_qty": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Qty" + } + }, + "State": "Generated", + "Terms": [ + { + "sales qty": { + "State": "Generated" + } + }, + { + "qty avg. mth": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.771 + } + }, + { + "qty average . month": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "sale qty": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + } + ] + }, + "sale.sales_amount": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount" + } + }, + "State": "Generated", + "Terms": [ + { + "sales amount": { + "State": "Generated" + } + }, + { + "sales": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "sale avg. mth": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.771 + } + }, + { + "sale avg. month": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "sale average . month": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "sale amount": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "sale quantity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "sale volume": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "sale expanse": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "sale extent": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "sale sum": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "sale total": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "sale.sales_amount__δ_LY": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount (Δ LY)" + } + }, + "State": "Generated", + "Terms": [ + { + "sales amount (δ LY)": { + "State": "Generated" + } + }, + { + "sales amount": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "sales amount (δ LY)": { + "State": "Generated", + "Weight": 0.97 + } + } + ] + }, + "sale.sales_amount_LY": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount (LY)" + } + }, + "State": "Generated", + "Terms": [ + { + "sales amount (LY)": { + "State": "Generated" + } + }, + { + "sales amount": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "sale amount (ly)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.605 + } + } + ] + }, + "sale.sales_amount_YTD_LY": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount (YTD, LY)" + } + }, + "State": "Generated", + "Terms": [ + { + "sales amount (YTD, LY)": { + "State": "Generated" + } + }, + { + "sales amount": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "sale.sales_amount_YTD": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount (YTD)" + } + }, + "State": "Generated", + "Terms": [ + { + "sales amount (YTD)": { + "State": "Generated" + } + }, + { + "sales amount": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "sale amount (ytd)": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.605 + } + }, + { + "sale amount ( year to date )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.528 + } + } + ] + }, + "sale.sales_amount_δ_YTD_LY": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount (Δ YTD, LY)" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "sales amount (δ YTD, LY)": { + "State": "Generated" + } + }, + { + "sales amount": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "sale.sales_amount__δ_YTD_LY": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount % (Δ YTD, LY)" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "sales amount % (δ YTD, LY)": { + "State": "Generated" + } + }, + { + "sales amount %": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "sale.sales_amount_avg_per_day": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount Avg per Day" + } + }, + "State": "Generated", + "Terms": [ + { + "sales amount avg per day": { + "State": "Generated" + } + }, + { + "sale amount average per day": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.757 + } + }, + { + "sale amount avg per day": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.605 + } + } + ] + }, + "sale.product_key": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "ProductKey" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "product key": { + "State": "Generated" + } + }, + { + "ProductKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "artifact key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "item key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "merchandise key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "produce key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "central": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "major": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "keynote": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "sale.store_key": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "StoreKey" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "store key": { + "State": "Generated" + } + }, + { + "StoreKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "central": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "major": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "keynote": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "essential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "store solution": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.475 + } + }, + { + "store explanation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.475 + } + }, + { + "store basis": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.475 + } + } + ] + }, + "sale.customer_key": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "CustomerKey" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "customer key": { + "State": "Generated" + } + }, + { + "CustomerKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "client key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "consumer key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "user key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "buyer key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "patron key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "purchaser key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "shopper key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "sale.v_Sales_Qty_FormatString_sales_qty_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Qty FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales qty FormatString": { + "State": "Generated" + } + }, + { + "_ sale qty formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + } + ] + }, + "sale.v_Sales_Amount_FormatString_sales_amount_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount FormatString": { + "State": "Generated" + } + }, + { + "_ sale amount formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + } + ] + }, + "sale.v_Sales_Amount___Δ_LY__FormatString_sales_amount__δ_LY_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount (Δ LY) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount (δ LY) FormatString": { + "State": "Generated" + } + }, + { + "_sales amount (δ LY) FormatString": { + "State": "Generated", + "Weight": 0.97 + } + } + ] + }, + "sale.v_Sales_Amount__LY__FormatString_sales_amount_LY_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount (LY) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount (LY) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v_Sales_Amount__YTD__LY__FormatString_sales_amount_YTD_LY_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount (YTD, LY) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount (YTD, LY) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v_Sales_Amount__YTD__FormatString_sales_amount_YTD_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount (YTD) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount (YTD) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v_Sales_Amount__Δ_YTD__LY__FormatString_sales_amount_δ_YTD_LY_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount (Δ YTD, LY) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount (δ YTD, LY) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.delivery_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Delivery Date" + } + }, + "State": "Generated", + "Terms": [ + { + "delivery date": { + "State": "Generated" + } + }, + { + "date": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "delivery moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "delivery period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ], + "SemanticType": "Time" + }, + "sale.v_Sales_Amount____Δ_YTD__LY__FormatString_sales_amount__δ_YTD_LY_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount % (Δ YTD, LY) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount % (δ YTD, LY) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.order_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Order Date" + } + }, + "State": "Generated", + "Terms": [ + { + "order date": { + "State": "Generated" + } + }, + { + "date": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "order moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "order period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ], + "SemanticType": "Time" + }, + "sale.line_number": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Line Number" + } + }, + "State": "Generated", + "Terms": [ + { + "line number": { + "State": "Generated" + } + }, + { + "line no": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ] + }, + "sale.order_number": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Order Number" + } + }, + "State": "Generated", + "Terms": [ + { + "order number": { + "State": "Generated" + } + }, + { + "order no": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ] + }, + "calendar.month_start_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Calendar", + "ConceptualProperty": "Month Start Date" + } + }, + "State": "Generated", + "Terms": [ + { + "month start date": { + "State": "Generated" + } + }, + { + "start date": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "month start moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.762 + } + }, + { + "start moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "month start period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "start period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "month commencement date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.615 + } + }, + { + "month inception date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.615 + } + }, + { + "month kickoff date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.615 + } + }, + { + "mth start date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + }, + { + "commencement date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "inception date": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + } + ], + "SemanticType": "Time" + }, + "sale.exchange_rate": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Exchange Rate" + } + }, + "State": "Generated", + "Terms": [ + { + "exchange rate": { + "State": "Generated" + } + }, + { + "rate": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "exchange degree": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "exchange frequency": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "exchange percentage": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "exchange ratio": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "exchange quotient": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "degree": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "frequency": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "ratio": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "quotient": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "exchange amount": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "sale.customers_with_sales": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "# Customers (with Sales)" + } + }, + "State": "Generated", + "Terms": [ + { + "# customers (with sales)": { + "State": "Generated" + } + }, + { + "# customers": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "sale.products_with_sales": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "# Products (with Sales)" + } + }, + "State": "Generated", + "Terms": [ + { + "# products (with sales)": { + "State": "Generated" + } + }, + { + "# products": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "sale.sales_amount___δ_LY": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Sales Amount (% Δ LY)" + } + }, + "State": "Generated", + "Terms": [ + { + "sales amount (% δ LY)": { + "State": "Generated" + } + }, + { + "sales amount": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "sales amount (% δ LY)": { + "State": "Generated", + "Weight": 0.97 + } + } + ] + }, + "sale.margin": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Margin" + } + }, + "State": "Generated", + "Terms": [ + { + "margin": { + "State": "Generated" + } + }, + { + "boundary": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "perimeter": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "periphery": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "border": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "brim": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "sideline": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "edge": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "verge": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "fringe": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "side": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "sale.margin_ly": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "Margin (ly)" + } + }, + "State": "Generated", + "Terms": [ + { + "margin (ly)": { + "State": "Generated" + } + }, + { + "margin": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "sale.sales": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "# Sales" + } + }, + "State": "Generated", + "Terms": [ + { + "# sales": { + "State": "Generated" + } + }, + { + "# sale": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "no sale": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "num sale": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "number sale": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + } + ] + }, + "sale.v___Customers__with_Sales__FormatString__customers_with_sales_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_# Customers (with Sales) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_# customers (with sales) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v___Products__with_Sales__FormatString__products_with_sales_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_# Products (with Sales) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_# products (with sales) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v_Sales_Amount_Avg_per_Day_FormatString_sales_amount_avg_per_day_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount Avg per Day FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount avg per day FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v_Sales_Amount_____Δ_LY__FormatString_sales_amount___δ_LY_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Sales Amount (% Δ LY) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_sales amount (% δ LY) FormatString": { + "State": "Generated" + } + }, + { + "_sales amount (% δ LY) FormatString": { + "State": "Generated", + "Weight": 0.97 + } + } + ] + }, + "sale.v_Margin_FormatString_margin_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Margin FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_margin FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v_Margin__ly__FormatString_margin_ly_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_Margin (ly) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_margin (ly) FormatString": { + "State": "Generated" + } + } + ] + }, + "sale.v___Sales_FormatString__sales_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Sales", + "ConceptualProperty": "_# Sales FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_# sales FormatString": { + "State": "Generated" + } + }, + { + "_ no sale formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ num sale formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ number sale formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_# sale formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + } + ] + }, + "product": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product" + } + }, + "State": "Generated", + "Terms": [ + { + "product": { + "State": "Generated" + } + }, + { + "artifact": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "item": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "merchandise": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "produce": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ] + }, + "product.product": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Product" + } + }, + "State": "Generated", + "Terms": [ + { + "product": { + "State": "Generated" + } + }, + { + "product name": { + "State": "Generated" + } + }, + { + "artifact": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "item": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "merchandise": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "produce": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ], + "NameType": "Name" + }, + "product.product_key": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "ProductKey" + } + }, + "State": "Generated", + "Terms": [ + { + "product key": { + "State": "Generated" + } + }, + { + "ProductKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "artifact key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "item key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "merchandise key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "produce key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "central": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "major": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "keynote": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "product.product_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Product Code" + } + }, + "State": "Generated", + "Terms": [ + { + "product code": { + "State": "Generated" + } + }, + { + "code": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "product id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "artifact code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "item code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "merchandise code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "produce code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + } + ], + "NameType": "Identifier" + }, + "product.manufacturer": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Manufacturer" + } + }, + "State": "Generated", + "Terms": [ + { + "manufacturer": { + "State": "Generated" + } + }, + { + "builder": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "producer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "constructer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "creator": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "industrialist": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "maker": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "company": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "firm": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "product.brand": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Brand" + } + }, + "State": "Generated", + "Terms": [ + { + "brand": { + "State": "Generated" + } + }, + { + "variety": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "marque": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "make": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "kind": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "trademark": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "type": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "style": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "class": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "strain": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "cast": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "product.color": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Color" + } + }, + "State": "Generated", + "Terms": [ + { + "color": { + "State": "Generated" + } + }, + { + "hue": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "tint": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "shade": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "dye": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "paint": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "pigment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "product.weight_unit_measure": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Weight Unit Measure" + } + }, + "State": "Generated", + "Terms": [ + { + "weight unit measure": { + "State": "Generated" + } + }, + { + "unit measure": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "weight module measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + }, + { + "weight element measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + }, + { + "weight entity measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.609 + } + }, + { + "module measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "element measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "entity measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "weight group measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.591 + } + }, + { + "weight component measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.591 + } + }, + { + "weight constituent measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.591 + } + }, + { + "weight item measure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.591 + } + } + ] + }, + "product.weight": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Weight" + } + }, + "State": "Generated", + "Terms": [ + { + "weight": { + "State": "Generated" + } + }, + { + "heaviness": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "weightiness": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "mass": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "bulk": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "heft": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "encumbrance": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "burden": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "load": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + } + ] + }, + "product.unit_cost": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Unit Cost" + } + }, + "State": "Generated", + "Terms": [ + { + "unit cost": { + "State": "Generated" + } + }, + { + "cost": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "module cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "element cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "entity cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "group cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "component cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "constituent cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "item cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "part cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "section cost": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "unit charge": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "product.unit_price": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Unit Price" + } + }, + "State": "Generated", + "Terms": [ + { + "unit price": { + "State": "Generated" + } + }, + { + "price": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "module price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "element price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "entity price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "group price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "component price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "constituent price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "item price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "part price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "section price": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "unit value": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "product.subcategory_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Subcategory Code" + } + }, + "State": "Generated", + "Terms": [ + { + "subcategory code": { + "State": "Generated" + } + }, + { + "subcategory id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "subcategory key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ] + }, + "product.subcategory": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Subcategory" + } + }, + "State": "Generated", + "Terms": [ + { + "subcategory": { + "State": "Generated" + } + } + ] + }, + "product.category_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Category Code" + } + }, + "State": "Generated", + "Terms": [ + { + "category code": { + "State": "Generated" + } + }, + { + "category id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "category key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "classification code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "class code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "group code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "type code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "grouping code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "kind code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + } + ] + }, + "product.category": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Category" + } + }, + "State": "Generated", + "Terms": [ + { + "category": { + "State": "Generated" + } + }, + { + "classification": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "class": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "type": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "grouping": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "kind": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ] + }, + "product.products": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "# Products" + } + }, + "State": "Generated", + "Terms": [ + { + "# products": { + "State": "Generated" + } + }, + { + "# artifact": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "# item": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "# merchandise": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "# produce": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "# goods": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.625 + } + }, + { + "no product": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "num product": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "number product": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + } + ] + }, + "product.v___Products_FormatString__products_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "_# Products FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_# products FormatString": { + "State": "Generated" + } + }, + { + "_# artifact formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + }, + { + "_ no product formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ num product formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ number product formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_# item formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_# merchandise formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_# produce formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_# goods formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.514 + } + } + ] + }, + "customer": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer" + } + }, + "State": "Generated", + "Terms": [ + { + "customer": { + "State": "Generated" + } + }, + { + "client": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "consumer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "user": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "buyer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "patron": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "purchaser": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "shopper": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + } + ] + }, + "customer.customer_key": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "CustomerKey" + } + }, + "State": "Generated", + "Terms": [ + { + "customer key": { + "State": "Generated" + } + }, + { + "CustomerKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "client key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "consumer key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "user key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "buyer key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "patron key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "purchaser key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "shopper key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "customer.gender": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Gender" + } + }, + "State": "Generated", + "Terms": [ + { + "gender": { + "State": "Generated" + } + }, + { + "sexuality": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "sex": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ] + }, + "customer.customer": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Customer" + } + }, + "State": "Generated", + "Terms": [ + { + "customer": { + "State": "Generated" + } + }, + { + "customer name": { + "State": "Generated" + } + }, + { + "client": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "consumer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "user": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "buyer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "patron": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "purchaser": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "shopper": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + } + ], + "NameType": "Name" + }, + "customer.address": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Address" + } + }, + "State": "Generated", + "Terms": [ + { + "address": { + "State": "Generated" + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "direction": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "residence": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "contact": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "place": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ] + }, + "customer.city": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "City" + } + }, + "State": "Generated", + "Terms": [ + { + "city": { + "State": "Generated" + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "metropolis": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "municipality": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "town": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "metropolitan": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ], + "SemanticType": "Location" + }, + "customer.state_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "State Code" + } + }, + "State": "Generated", + "Terms": [ + { + "state code": { + "State": "Generated" + } + }, + { + "state or province": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "state id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "state key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "location code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "province code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "territory code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "nation code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "condition code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + } + ], + "SemanticType": "Location" + }, + "customer.state": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "State" + } + }, + "State": "Generated", + "Terms": [ + { + "state": { + "State": "Generated" + } + }, + { + "state or province": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "province": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "territory": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "nation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "condition": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ], + "SemanticType": "Location" + }, + "customer.zip_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Zip Code" + } + }, + "State": "Generated", + "Terms": [ + { + "zip code": { + "State": "Generated" + } + }, + { + "postal code": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "zip id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "zip key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.54 + } + }, + { + "postcode": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.54 + } + }, + { + "post code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.524 + } + } + ], + "SemanticType": "Location" + }, + "customer.country_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Country Code" + } + }, + "State": "Generated", + "Terms": [ + { + "country code": { + "State": "Generated" + } + }, + { + "country id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "country key": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + }, + { + "nation code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + }, + { + "location code": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.6 + } + } + ], + "SemanticType": "Location" + }, + "customer.country": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Country" + } + }, + "State": "Generated", + "Terms": [ + { + "country": { + "State": "Generated" + } + }, + { + "nation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + } + ], + "SemanticType": "Location" + }, + "customer.continent": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Continent" + } + }, + "State": "Generated", + "Terms": [ + { + "continent": { + "State": "Generated" + } + }, + { + "landmass": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "landform": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "region": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "land": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "island": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "mainland": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "zone": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ], + "SemanticType": "Location" + }, + "customer.birthday": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Birthday" + } + }, + "State": "Generated", + "Terms": [ + { + "birthday": { + "State": "Generated" + } + }, + { + "date": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "birthdate": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "anniversary": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "centenary": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "bicentenary": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "centennial": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "bicentennial": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + } + ], + "SemanticType": "Time" + }, + "customer.age": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Age" + } + }, + "State": "Generated", + "Terms": [ + { + "age": { + "State": "Generated" + } + }, + { + "oldness": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "stage": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "phase": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "era": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "epoch": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + } + ] + }, + "customer.customers": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "# Customers" + } + }, + "State": "Generated", + "Terms": [ + { + "# customers": { + "State": "Generated" + } + }, + { + "no customer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "num customer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "number customer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "# clientele": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# patron": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# client": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# consumer": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# punter": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# regular": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "# custom": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "customer.v___Customers_FormatString__customers_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "_# Customers FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_# customers FormatString": { + "State": "Generated" + } + }, + { + "_ no customer formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ num customer formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ number customer formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + } + ] + }, + "smart_calc": { + "Definition": { + "Binding": { + "ConceptualEntity": "Smart Calcs" + } + }, + "State": "Generated", + "Terms": [ + { + "smart calc": { + "State": "Generated" + } + } + ] + }, + "smart_calc.smart_calc": { + "Definition": { + "Binding": { + "ConceptualEntity": "Smart Calcs", + "ConceptualProperty": "Smart Calc" + } + }, + "State": "Generated", + "Terms": [ + { + "smart calc": { + "State": "Generated" + } + }, + { + "smart calc name": { + "State": "Generated" + } + } + ], + "NameType": "Name" + }, + "smart_calc.ordinal": { + "Definition": { + "Binding": { + "ConceptualEntity": "Smart Calcs", + "ConceptualProperty": "Ordinal" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "ordinal": { + "State": "Generated" + } + } + ] + }, + "dynamic_measure": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + } + }, + "State": "Generated", + "Terms": [ + { + "dynamic measure": { + "State": "Generated" + } + }, + { + "dynamic degree": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "dynamic quantity": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "dynamic quota": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "dynamic extent": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "dynamic amount": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "dynamic portion": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "dynamic ration": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "dynamic size": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "dynamic_measure.code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Code" + } + }, + "State": "Generated", + "Terms": [ + { + "code": { + "State": "Generated" + } + } + ] + }, + "dynamic_measure.order": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Order" + } + }, + "State": "Generated", + "Terms": [ + { + "order": { + "State": "Generated" + } + }, + { + "instruction": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "direction": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "edict": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "command": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "directive": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "demand": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "mandate": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "imperative": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "stability": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "harmony": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + } + ] + }, + "dynamic_measure.measure": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Measure" + } + }, + "State": "Generated", + "Terms": [ + { + "measure": { + "State": "Generated" + } + }, + { + "degree": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "quota": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "extent": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "portion": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "ration": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "size": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "dynamic_measure.area": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Area" + } + }, + "State": "Generated", + "Terms": [ + { + "area": { + "State": "Generated" + } + }, + { + "region": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "locale": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "locality": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "neighbourhood": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "district": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "field": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "neighborhood": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "section": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "space": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ] + }, + "dynamic_measure.format": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Format" + } + }, + "State": "Generated", + "Terms": [ + { + "format": { + "State": "Generated" + } + }, + { + "arrangement": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "setup": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "presentation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "organization": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "layout": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "system": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "set-up": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "plan": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "design": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "structure": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + } + ] + }, + "dynamic_measure.value": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Value" + } + }, + "State": "Generated", + "Terms": [ + { + "value": { + "State": "Generated" + } + }, + { + "assessment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "worth": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "charge": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "importance": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "significance": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "usefulness": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "consequence": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "use": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "meaning": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + }, + { + "merit": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.452 + } + } + ] + }, + "dynamic_measure.value_ly": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Value (ly)" + } + }, + "State": "Generated", + "Terms": [ + { + "value (ly)": { + "State": "Generated" + } + }, + { + "value": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "dynamic_measure.value_ytd": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Value (ytd)" + } + }, + "State": "Generated", + "Terms": [ + { + "value (ytd)": { + "State": "Generated" + } + }, + { + "value": { + "State": "Generated", + "Weight": 0.75 + } + }, + { + "value ( year to date )": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.514 + } + } + ] + }, + "dynamic_measure.value_avg_per_month": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Value Avg per Month" + } + }, + "State": "Generated", + "Terms": [ + { + "value avg per month": { + "State": "Generated" + } + }, + { + "value average per month": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.748 + } + }, + { + "value avg per mth": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.617 + } + } + ] + }, + "dynamic_measure.value_daily_max": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Value Daily Max" + } + }, + "State": "Generated", + "Terms": [ + { + "value daily max": { + "State": "Generated" + } + }, + { + "daily max": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "value daily maximum": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.739 + } + }, + { + "daily maximum": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ] + }, + "dynamic_measure.value__δ_ly": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Value % (Δ ly)" + } + }, + "State": "Generated", + "Terms": [ + { + "value % (δ ly)": { + "State": "Generated" + } + }, + { + "value %": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "dynamic_measure.value_normalized_by_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "Value Normalized (by date)" + } + }, + "State": "Generated", + "Terms": [ + { + "value normalized (by date)": { + "State": "Generated" + } + }, + { + "value normalized": { + "State": "Generated", + "Weight": 0.75 + } + } + ] + }, + "dynamic_measure.v_Value_FormatString_value_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "_Value FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_value FormatString": { + "State": "Generated" + } + } + ] + }, + "dynamic_measure.v_Value__ly__FormatString_value_ly_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "_Value (ly) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_value (ly) FormatString": { + "State": "Generated" + } + } + ] + }, + "dynamic_measure.v_Value__ytd__FormatString_value_ytd_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "_Value (ytd) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_value (ytd) FormatString": { + "State": "Generated" + } + } + ] + }, + "dynamic_measure.v_Value_Avg_per_Month_FormatString_value_avg_per_month_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "_Value Avg per Month FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_value avg per month FormatString": { + "State": "Generated" + } + } + ] + }, + "dynamic_measure.v_Value_Daily_Max_FormatString_value_daily_max_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "_Value Daily Max FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_value daily max FormatString": { + "State": "Generated" + } + }, + { + "_value daily maximum formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.605 + } + } + ] + }, + "dynamic_measure.v_Value____Δ_ly__FormatString_value__δ_ly_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "_Value % (Δ ly) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_value % (δ ly) FormatString": { + "State": "Generated" + } + } + ] + }, + "dynamic_measure.v_Value_Normalized__by_date__FormatString_value_normalized_by_date_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Dynamic Measure", + "ConceptualProperty": "_Value Normalized (by date) FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_value normalized (by date) FormatString": { + "State": "Generated" + } + } + ] + }, + "store": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store" + } + }, + "State": "Generated", + "Terms": [ + { + "store": { + "State": "Generated" + } + }, + { + "accumulation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "collection": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "stock": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "supply": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "stockpile": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "hoard": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "mass": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "pile": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "storehouse": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "storeroom": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + } + ] + }, + "store.store_key": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "StoreKey" + } + }, + "State": "Generated", + "Terms": [ + { + "store key": { + "State": "Generated" + } + }, + { + "StoreKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "central": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "major": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "keynote": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "essential": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "store solution": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.475 + } + }, + { + "store explanation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.475 + } + }, + { + "store basis": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.475 + } + } + ] + }, + "store.store_code": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "Store Code" + } + }, + "State": "Generated", + "Terms": [ + { + "store code": { + "State": "Generated" + } + }, + { + "code": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "store id": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + } + ], + "NameType": "Identifier" + }, + "store.country": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "Country" + } + }, + "State": "Generated", + "Terms": [ + { + "country": { + "State": "Generated" + } + }, + { + "nation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + } + ] + }, + "store.state": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "State" + } + }, + "State": "Generated", + "Terms": [ + { + "state": { + "State": "Generated" + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "province": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "territory": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "nation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "condition": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ] + }, + "store.store": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "Store" + } + }, + "State": "Generated", + "Terms": [ + { + "store": { + "State": "Generated" + } + }, + { + "store name": { + "State": "Generated" + } + }, + { + "accumulation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "collection": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "stock": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "supply": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "stockpile": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "hoard": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "mass": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "pile": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.476 + } + }, + { + "storehouse": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + }, + { + "storeroom": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.466 + } + } + ], + "NameType": "Name" + }, + "store.square_meter": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "Square Meters" + } + }, + "State": "Generated", + "Terms": [ + { + "square meter": { + "State": "Generated" + } + }, + { + "meter": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "square rhythm": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "square tempo": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "square cadence": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "rhythm": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "tempo": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "cadence": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.491 + } + }, + { + "square beat": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "square pulse": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "square pattern": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "square stress": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + } + ] + }, + "store.open_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "Open Date" + } + }, + "State": "Generated", + "Terms": [ + { + "open date": { + "State": "Generated" + } + }, + { + "date": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "open moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "open period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ], + "SemanticType": "Time" + }, + "store.close_date": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "Close Date" + } + }, + "State": "Generated", + "Terms": [ + { + "close date": { + "State": "Generated" + } + }, + { + "date": { + "State": "Generated", + "Weight": 0.7 + } + }, + { + "close moment": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.75 + } + }, + { + "close period": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.727 + } + } + ], + "SemanticType": "Time" + }, + "store.status": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "Status" + } + }, + "State": "Generated", + "Terms": [ + { + "status": { + "State": "Generated" + } + }, + { + "importance": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "prestige": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "prominence": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.736 + } + }, + { + "condition": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "class": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "grade": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "level": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + }, + { + "position": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.714 + } + } + ] + }, + "store.stores": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "# Stores" + } + }, + "State": "Generated", + "Terms": [ + { + "# stores": { + "State": "Generated" + } + }, + { + "no store": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "num store": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "number store": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.582 + } + }, + { + "# goods": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# foods": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# vittles": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.5 + } + }, + { + "# supply": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "# provision": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "# ration": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.485 + } + }, + { + "# accumulation": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.475 + } + } + ] + }, + "store.v___Stores_FormatString__stores_FormatString": { + "Definition": { + "Binding": { + "ConceptualEntity": "Store", + "ConceptualProperty": "_# Stores FormatString" + } + }, + "State": "Generated", + "Visibility": { + "Value": "Hidden" + }, + "Terms": [ + { + "_# stores FormatString": { + "State": "Generated" + } + }, + { + "_ no store formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ num store formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + }, + { + "_ number store formatstring": { + "Type": "Noun", + "State": "Suggested", + "Source": { + "Agent": "OfficeThesaurus" + }, + "Weight": 0.599 + } + } + ] + } + }, + "Relationships": { + "calendar_has_day_relative": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.day_relative": { + "Target": { + "Entity": "calendar.day_relative" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.day_relative" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_month_": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.month_": { + "Target": { + "Entity": "calendar.month_" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.month_" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_week_day_": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week_day_": { + "Target": { + "Entity": "calendar.week_day_" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.week_day_" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_semester1": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.semester1": { + "Target": { + "Entity": "calendar.semester1" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.semester1" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_quarter1": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.quarter1": { + "Target": { + "Entity": "calendar.quarter1" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.quarter1" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_month1": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.month1": { + "Target": { + "Entity": "calendar.month1" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.month1" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_week1": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week1": { + "Target": { + "Entity": "calendar.week1" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.week1" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_year": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.year": { + "Target": { + "Entity": "calendar.year" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.year" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_semester": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.semester": { + "Target": { + "Entity": "calendar.semester" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.semester" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_quarter": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.quarter": { + "Target": { + "Entity": "calendar.quarter" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.quarter" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_month": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.month": { + "Target": { + "Entity": "calendar.month" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.month" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_week": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week": { + "Target": { + "Entity": "calendar.week" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.week" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_week_day": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week_day": { + "Target": { + "Entity": "calendar.week_day" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.week_day" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_day": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.day": { + "Target": { + "Entity": "calendar.day" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.day" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_date": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.date": { + "Target": { + "Entity": "calendar.date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_in_state": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.state": { + "Target": { + "Entity": "store.state" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "store.state" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "store" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "store.state" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_in_country": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.country": { + "Target": { + "Entity": "store.country" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "store.country" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "store" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "store.country" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_in_area": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.area": { + "Target": { + "Entity": "dynamic_measure.area" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "dynamic_measure.area" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "dynamic_measure" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "dynamic_measure.area" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_continent": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.continent": { + "Target": { + "Entity": "customer.continent" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.continent" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.continent" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_country": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.country": { + "Target": { + "Entity": "customer.country" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.country" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.country" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_country_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.country_code": { + "Target": { + "Entity": "customer.country_code" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.country_code" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.country_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_zip_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.zip_code": { + "Target": { + "Entity": "customer.zip_code" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.zip_code" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.zip_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_state": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.state": { + "Target": { + "Entity": "customer.state" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.state" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.state" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_state_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.state_code": { + "Target": { + "Entity": "customer.state_code" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.state_code" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.state_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_city": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.city": { + "Target": { + "Entity": "customer.city" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.city" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.city" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_address": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.address": { + "Target": { + "Entity": "customer.address" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.address" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer.address" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_is_born_on_birthday": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.birthday": { + "Target": { + "Entity": "customer.birthday" + } + } + }, + "SemanticSlots": { + "When": { + "Role": "customer.birthday" + } + }, + "Phrasings": [ + { + "Verb": { + "Verbs": [ + { + "bear": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "customer" + } + }, + "State": "Generated", + "Weight": 0.9 + } + ] + }, + "store_is_closed_on_close_date": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.close_date": { + "Target": { + "Entity": "store.close_date" + } + } + }, + "SemanticSlots": { + "When": { + "Role": "store.close_date" + } + }, + "Phrasings": [ + { + "Verb": { + "Verbs": [ + { + "close": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "store" + } + }, + "State": "Generated", + "Weight": 0.9 + } + ] + }, + "store_is_opened_on_open_date": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.open_date": { + "Target": { + "Entity": "store.open_date" + } + } + }, + "SemanticSlots": { + "When": { + "Role": "store.open_date" + } + }, + "Phrasings": [ + { + "Verb": { + "Verbs": [ + { + "open": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "store" + } + }, + "State": "Generated", + "Weight": 0.9 + } + ] + }, + "calendar_is_started_on_month_start_date": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.month_start_date": { + "Target": { + "Entity": "calendar.month_start_date" + } + } + }, + "SemanticSlots": { + "When": { + "Role": "calendar.month_start_date" + } + }, + "Phrasings": [ + { + "Verb": { + "Verbs": [ + { + "start": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "calendar" + } + }, + "State": "Generated", + "Weight": 0.9 + } + ] + }, + "sale_is_ordered_on_order_date": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.order_date": { + "Target": { + "Entity": "sale.order_date" + } + } + }, + "SemanticSlots": { + "When": { + "Role": "sale.order_date" + } + }, + "Phrasings": [ + { + "Verb": { + "Verbs": [ + { + "order": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "sale" + } + }, + "State": "Generated", + "Weight": 0.9 + } + ] + }, + "calendar_is_ended_on_week_end_date": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week_end_date": { + "Target": { + "Entity": "calendar.week_end_date" + } + } + }, + "SemanticSlots": { + "When": { + "Role": "calendar.week_end_date" + } + }, + "Phrasings": [ + { + "Verb": { + "Verbs": [ + { + "end": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "calendar" + } + }, + "State": "Generated", + "Weight": 0.9 + } + ] + }, + "calendar_is_started_on_week_start_date": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week_start_date": { + "Target": { + "Entity": "calendar.week_start_date" + } + } + }, + "SemanticSlots": { + "When": { + "Role": "calendar.week_start_date" + } + }, + "Phrasings": [ + { + "Verb": { + "Verbs": [ + { + "start": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "calendar" + } + }, + "State": "Generated", + "Weight": 0.9 + } + ] + }, + "store_is_status": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.status": { + "Target": { + "Entity": "store.status" + } + } + }, + "Phrasings": [ + { + "DynamicAdjective": { + "Subject": { + "Role": "store" + }, + "Adjective": { + "Role": "store.status" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.status" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_is_old": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.age": { + "Target": { + "Entity": "customer.age" + } + } + }, + "Phrasings": [ + { + "Adjective": { + "Subject": { + "Role": "customer" + }, + "Adjectives": [ + { + "old": { + "State": "Generated" + } + } + ], + "Antonyms": [ + { + "young": { + "State": "Generated" + } + } + ], + "Measurement": { + "Role": "customer.age" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.age" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_expensive1": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.unit_price": { + "Target": { + "Entity": "product.unit_price" + } + } + }, + "Phrasings": [ + { + "Adjective": { + "Subject": { + "Role": "product" + }, + "Adjectives": [ + { + "expensive": { + "State": "Generated" + } + } + ], + "Antonyms": [ + { + "cheap": { + "State": "Generated" + } + } + ], + "Measurement": { + "Role": "product.unit_price" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.unit_price" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_expensive": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.unit_cost": { + "Target": { + "Entity": "product.unit_cost" + } + } + }, + "Phrasings": [ + { + "Adjective": { + "Subject": { + "Role": "product" + }, + "Adjectives": [ + { + "expensive": { + "State": "Generated" + } + } + ], + "Antonyms": [ + { + "cheap": { + "State": "Generated" + } + } + ], + "Measurement": { + "Role": "product.unit_cost" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.unit_cost" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_heavy": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.weight": { + "Target": { + "Entity": "product.weight" + } + } + }, + "Phrasings": [ + { + "Adjective": { + "Subject": { + "Role": "product" + }, + "Adjectives": [ + { + "heavy": { + "State": "Generated" + } + } + ], + "Antonyms": [ + { + "light": { + "State": "Generated" + } + } + ], + "Measurement": { + "Role": "product.weight" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.weight" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_is_expensive": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.net_price": { + "Target": { + "Entity": "sale.net_price" + } + } + }, + "Phrasings": [ + { + "Adjective": { + "Subject": { + "Role": "sale" + }, + "Adjectives": [ + { + "expensive": { + "State": "Generated" + } + } + ], + "Antonyms": [ + { + "cheap": { + "State": "Generated" + } + } + ], + "Measurement": { + "Role": "sale.net_price" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.net_price" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_manufacturer_manufacture_product": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.manufacturer": { + "Target": { + "Entity": "product.manufacturer" + } + }, + "product": { + "Target": { + "Entity": "product" + } + } + }, + "Phrasings": [ + { + "Verb": { + "Subject": { + "Role": "product.manufacturer" + }, + "Verbs": [ + { + "manufacture": { + "State": "Generated" + } + } + ], + "Object": { + "Role": "product" + } + }, + "State": "Generated", + "Weight": 0.75 + }, + { + "Attribute": { + "Subject": { + "Role": "product.manufacturer" + }, + "Object": { + "Role": "product" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.manufacturer" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_is_named_store": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.store": { + "Target": { + "Entity": "store.store" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "store" + }, + "Name": { + "Role": "store.store" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.store" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_is_named_store_code": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.store_code": { + "Target": { + "Entity": "store.store_code" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "store" + }, + "Name": { + "Role": "store.store_code" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.store_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "smart_calc_is_named_smart_calc": { + "Binding": { + "ConceptualEntity": "Smart Calcs" + }, + "State": "Generated", + "Roles": { + "smart_calc": { + "Target": { + "Entity": "smart_calc" + } + }, + "smart_calc.smart_calc": { + "Target": { + "Entity": "smart_calc.smart_calc" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "smart_calc" + }, + "Name": { + "Role": "smart_calc.smart_calc" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "smart_calc" + }, + "Object": { + "Role": "smart_calc.smart_calc" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_is_named_customer": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.customer": { + "Target": { + "Entity": "customer.customer" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "customer" + }, + "Name": { + "Role": "customer.customer" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.customer" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_named_product_code": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.product_code": { + "Target": { + "Entity": "product.product_code" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "product" + }, + "Name": { + "Role": "product.product_code" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.product_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_named_product": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.product": { + "Target": { + "Entity": "product.product" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "product" + }, + "Name": { + "Role": "product.product" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.product" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_month_relative": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.month_relative": { + "Target": { + "Entity": "calendar.month_relative" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.month_relative" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_year_relative": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.year_relative": { + "Target": { + "Entity": "calendar.year_relative" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.year_relative" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_work_day": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.work_day": { + "Target": { + "Entity": "calendar.work_day" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.work_day" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_month_long": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.month_long": { + "Target": { + "Entity": "calendar.month_long" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.month_long" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_week_relative": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week_relative": { + "Target": { + "Entity": "calendar.week_relative" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.week_relative" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_week_start_date": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week_start_date": { + "Target": { + "Entity": "calendar.week_start_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.week_start_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_week_end_date": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.week_end_date": { + "Target": { + "Entity": "calendar.week_end_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.week_end_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_currency_code": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.currency_code": { + "Target": { + "Entity": "sale.currency_code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.currency_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_qty": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_qty": { + "Target": { + "Entity": "sale.sales_qty" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_qty" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_amount": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_amount": { + "Target": { + "Entity": "sale.sales_amount" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_amount" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_amount__δ_LY": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_amount__δ_LY": { + "Target": { + "Entity": "sale.sales_amount__δ_LY" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_amount__δ_LY" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_amount_LY": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_amount_LY": { + "Target": { + "Entity": "sale.sales_amount_LY" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_amount_LY" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_amount_YTD_LY": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_amount_YTD_LY": { + "Target": { + "Entity": "sale.sales_amount_YTD_LY" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_amount_YTD_LY" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_amount_YTD": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_amount_YTD": { + "Target": { + "Entity": "sale.sales_amount_YTD" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_amount_YTD" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_sale_sales_amount_avg_per_day": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "sale.sales_amount_avg_per_day": { + "Target": { + "Entity": "sale.sales_amount_avg_per_day" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "sale.sales_amount_avg_per_day" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_amount_avg_per_day": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_amount_avg_per_day": { + "Target": { + "Entity": "sale.sales_amount_avg_per_day" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_amount_avg_per_day" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_delivery_date": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.delivery_date": { + "Target": { + "Entity": "sale.delivery_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.delivery_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_order_date": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.order_date": { + "Target": { + "Entity": "sale.order_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.order_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_line_number": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.line_number": { + "Target": { + "Entity": "sale.line_number" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.line_number" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_order_number": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.order_number": { + "Target": { + "Entity": "sale.order_number" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.order_number" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "calendar_has_month_start_date": { + "Binding": { + "ConceptualEntity": "Calendar" + }, + "State": "Generated", + "Roles": { + "calendar": { + "Target": { + "Entity": "calendar" + } + }, + "calendar.month_start_date": { + "Target": { + "Entity": "calendar.month_start_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "calendar.month_start_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_exchange_rate": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.exchange_rate": { + "Target": { + "Entity": "sale.exchange_rate" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.exchange_rate" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_customers_with_sales": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.customers_with_sales": { + "Target": { + "Entity": "sale.customers_with_sales" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.customers_with_sales" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_products_with_sales": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.products_with_sales": { + "Target": { + "Entity": "sale.products_with_sales" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.products_with_sales" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales_amount___δ_LY": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales_amount___δ_LY": { + "Target": { + "Entity": "sale.sales_amount___δ_LY" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales_amount___δ_LY" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_margin": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.margin": { + "Target": { + "Entity": "sale.margin" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.margin" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_margin_ly": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.margin_ly": { + "Target": { + "Entity": "sale.margin_ly" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.margin_ly" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_sales": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "sale.sales": { + "Target": { + "Entity": "sale.sales" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "sale.sales" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_product_key": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.product_key": { + "Target": { + "Entity": "product.product_key" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.product_key" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_brand": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.brand": { + "Target": { + "Entity": "product.brand" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.brand" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_color": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.color": { + "Target": { + "Entity": "product.color" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.color" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_weight_unit_measure": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.weight_unit_measure": { + "Target": { + "Entity": "product.weight_unit_measure" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.weight_unit_measure" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_subcategory_code": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.subcategory_code": { + "Target": { + "Entity": "product.subcategory_code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.subcategory_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_subcategory": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.subcategory": { + "Target": { + "Entity": "product.subcategory" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.subcategory" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_category_code": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.category_code": { + "Target": { + "Entity": "product.category_code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.category_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_category": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.category": { + "Target": { + "Entity": "product.category" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.category" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_products": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.products": { + "Target": { + "Entity": "product.products" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.products" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_customer_key": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.customer_key": { + "Target": { + "Entity": "customer.customer_key" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.customer_key" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_gender": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.gender": { + "Target": { + "Entity": "customer.gender" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.gender" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_address": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.address": { + "Target": { + "Entity": "customer.address" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.address" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_city": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.city": { + "Target": { + "Entity": "customer.city" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.city" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_state_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.state_code": { + "Target": { + "Entity": "customer.state_code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.state_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_state": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.state": { + "Target": { + "Entity": "customer.state" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.state" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_zip_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.zip_code": { + "Target": { + "Entity": "customer.zip_code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.zip_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_country_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.country_code": { + "Target": { + "Entity": "customer.country_code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.country_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_country": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.country": { + "Target": { + "Entity": "customer.country" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.country" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_continent": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.continent": { + "Target": { + "Entity": "customer.continent" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.continent" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_birthday": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.birthday": { + "Target": { + "Entity": "customer.birthday" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.birthday" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_customers": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.customers": { + "Target": { + "Entity": "customer.customers" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.customers" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_code": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.code": { + "Target": { + "Entity": "dynamic_measure.code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_order": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.order": { + "Target": { + "Entity": "dynamic_measure.order" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.order" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_measure": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.measure": { + "Target": { + "Entity": "dynamic_measure.measure" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.measure" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_area": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.area": { + "Target": { + "Entity": "dynamic_measure.area" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.area" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_format": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.format": { + "Target": { + "Entity": "dynamic_measure.format" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.format" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_value": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.value": { + "Target": { + "Entity": "dynamic_measure.value" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.value" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_value_ly": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.value_ly": { + "Target": { + "Entity": "dynamic_measure.value_ly" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.value_ly" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_value_ytd": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.value_ytd": { + "Target": { + "Entity": "dynamic_measure.value_ytd" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.value_ytd" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_value_avg_per_month": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.value_avg_per_month": { + "Target": { + "Entity": "dynamic_measure.value_avg_per_month" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.value_avg_per_month" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_value_daily_max": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.value_daily_max": { + "Target": { + "Entity": "dynamic_measure.value_daily_max" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.value_daily_max" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_value__δ_ly": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.value__δ_ly": { + "Target": { + "Entity": "dynamic_measure.value__δ_ly" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.value__δ_ly" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "dynamic_measure_has_value_normalized_by_date": { + "Binding": { + "ConceptualEntity": "Dynamic Measure" + }, + "State": "Generated", + "Roles": { + "dynamic_measure": { + "Target": { + "Entity": "dynamic_measure" + } + }, + "dynamic_measure.value_normalized_by_date": { + "Target": { + "Entity": "dynamic_measure.value_normalized_by_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "dynamic_measure" + }, + "Object": { + "Role": "dynamic_measure.value_normalized_by_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_has_store_key": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.store_key": { + "Target": { + "Entity": "store.store_key" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.store_key" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_has_country": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.country": { + "Target": { + "Entity": "store.country" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.country" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_has_state": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.state": { + "Target": { + "Entity": "store.state" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.state" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_has_square_meter": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.square_meter": { + "Target": { + "Entity": "store.square_meter" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.square_meter" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_has_open_date": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.open_date": { + "Target": { + "Entity": "store.open_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.open_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_has_close_date": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.close_date": { + "Target": { + "Entity": "store.close_date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.close_date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "store_has_stores": { + "Binding": { + "ConceptualEntity": "Store" + }, + "State": "Generated", + "Roles": { + "store": { + "Target": { + "Entity": "store" + } + }, + "store.stores": { + "Target": { + "Entity": "store.stores" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "store.stores" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_customer": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "customer": { + "Target": { + "Entity": "customer" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "customer" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "sale" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_calendar": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "calendar": { + "Target": { + "Entity": "calendar" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "calendar" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "calendar" + }, + "Object": { + "Role": "sale" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_product": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "product": { + "Target": { + "Entity": "product" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "product" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "sale" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sale_has_store": { + "Binding": { + "ConceptualEntity": "Sales" + }, + "State": "Generated", + "Roles": { + "sale": { + "Target": { + "Entity": "sale" + } + }, + "store": { + "Target": { + "Entity": "store" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sale" + }, + "Object": { + "Role": "store" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "store" + }, + "Object": { + "Role": "sale" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + } + } + } + contentType: json + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/database.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/database.tmdl new file mode 100644 index 00000000..d3d691d8 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/database.tmdl @@ -0,0 +1,4 @@ +database Unknown + compatibilityLevel: 1601 + compatibilityMode: powerBI + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/expressions.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/expressions.tmdl new file mode 100644 index 00000000..541bf933 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/expressions.tmdl @@ -0,0 +1,185 @@ +expression HttpSource = "https://raw.githubusercontent.com/pbi-tools/sales-sample/data/" meta [IsParameterQuery=true, Type="Text", IsParameterQueryRequired=true] + lineageTag: 39c7bef4-452b-4b29-846c-f788ef1af01f + + annotation PBI_ResultType = Text + +expression RAW-Sales = + let + Source = Csv.Document(Web.Contents(HttpSource, [RelativePath = "RAW-Sales.csv"]),[Delimiter=",", Columns=13, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Column Types" = Table.TransformColumnTypes(#"Promoted Headers",{ + {"Order Number", Int64.Type}, + {"Line Number", Int64.Type}, + {"Order Date", type date}, + {"Delivery Date", type date}, + {"CustomerKey", Int64.Type}, + {"StoreKey", Int64.Type}, + {"ProductKey", Int64.Type}, + {"Quantity", Int64.Type}, + {"Unit Price", Currency.Type}, + {"Net Price", Currency.Type}, + {"Unit Cost", Currency.Type}, + {"Currency Code", type text}, + {"Exchange Rate", Currency.Type}}), + #"Added 'Time'" = Table.AddColumn(#"Changed Column Types" + , "Time" + , each #time(Number.RoundDown(Number.RandomBetween(0, 23),0), Number.RoundDown(Number.RandomBetween(0, 59),0),0) + , type time) + in + #"Added 'Time'" + lineageTag: a7c64317-d746-4e5a-813d-43bbf0439e23 + queryGroup: 'Raw Data' + + annotation PBI_ResultType = Table + +expression RAW-Product = + let + Source = Csv.Document(Web.Contents(HttpSource, [RelativePath = "RAW-Product.csv"]),[Delimiter=",", Columns=14, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Column Types" = Table.TransformColumnTypes(#"Promoted Headers",{ + {"ProductKey", Int64.Type}, + {"Product Code", type text}, + {"Product Name", type text}, + {"Manufacturer", type text}, + {"Brand", type text}, + {"Color", type text}, + {"Weight Unit Measure", type text}, + {"Weight", Currency.Type}, + {"Unit Cost", Currency.Type}, + {"Unit Price", Currency.Type}, + {"Subcategory Code", type text}, + {"Subcategory", type text}, + {"Category Code", type text}, + {"Category", type text}}) + in + #"Changed Column Types" + lineageTag: 20f062e9-ff67-42bf-9c62-97df6dc33a35 + queryGroup: 'Raw Data' + + annotation PBI_ResultType = Exception + +expression RAW-Customer = + let + Source = Csv.Document(Web.Contents(HttpSource, [RelativePath = "RAW-Customer.csv"]),[Delimiter=",", Columns=13, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Column Types" = Table.TransformColumnTypes(#"Promoted Headers",{ + {"CustomerKey", Int64.Type}, + {"Gender", type text}, + {"Name", type text}, + {"Address", type text}, + {"City", type text}, + {"State Code", type text}, + {"State", type text}, + {"Zip Code", type text}, + {"Country Code", type text}, + {"Country", type text}, + {"Continent", type text}, + {"Birthday", type date}, + {"Age", Int64.Type}}) + in + #"Changed Column Types" + lineageTag: cd034b2b-6d5e-4420-9158-90f5ead53ec3 + queryGroup: 'Raw Data' + + annotation PBI_ResultType = Table + +expression RAW-Store = + let + Source = Csv.Document(Web.Contents(HttpSource , [RelativePath = "RAW-Store.csv"]),[Delimiter=",", Columns=9, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Column Types" = Table.TransformColumnTypes(#"Promoted Headers",{ + {"StoreKey", Int64.Type}, + {"Store Code", type text}, + {"Country", type text}, + {"State", type text}, + {"Name", type text}, + {"Square Meters", Int64.Type}, + {"Open Date", type date}, + {"Close Date", type date}, + {"Status", type text}}) + in + #"Changed Column Types" + lineageTag: 524dec4a-0544-44a7-8d45-cd6794082fe6 + queryGroup: 'Raw Data' + + annotation PBI_ResultType = Table + +expression RAW-CurrencyExchange = + let + Source = Csv.Document(Web.Contents(HttpSource, [RelativePath = "RAW-CurrencyExchange.csv"]),[Delimiter=",", Columns=4, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Column Types" = Table.TransformColumnTypes(#"Promoted Headers",{ + {"Date", type date}, + {"FromCurrency", type text}, + {"ToCurrency", type text}, + {"Exchange", Currency.Type}}) + in + #"Changed Column Types" + lineageTag: 78c5038c-6adf-4b1f-85fa-879cb022b266 + queryGroup: 'Raw Data' + + annotation PBI_ResultType = Table + +expression RangeStart = #datetime(2020, 1, 1, 0, 0, 0) meta [IsParameterQuery=true, List={#datetime(2020, 1, 1, 0, 0, 0)}, DefaultValue=#datetime(2020, 1, 1, 0, 0, 0), Type="DateTime", IsParameterQueryRequired=true] + lineageTag: a1a6bc94-b59e-4ec1-9b6d-cd637ebafff5 + + annotation PBI_ResultType = DateTime + +expression RangeEnd = #datetime(2023, 12, 31, 0, 0, 0) meta [IsParameterQuery=true, List={#datetime(2023, 12, 31, 0, 0, 0)}, DefaultValue=#datetime(2023, 12, 31, 0, 0, 0), Type="DateTime", IsParameterQueryRequired=true] + lineageTag: 3230a904-a045-4d7e-9424-64fc69c93735 + + annotation PBI_ResultType = DateTime + +expression RAW-SalesDateAdjustedAndSalesRandomized = + let + Source = #"RAW-Sales", + minDate = List.Min(Source[Order Date]), + numYearsOnDummyData = 3, + yearsDiff = (Date.Year(DateTime.LocalNow()) - numYearsOnDummyData) - Date.Year(minDate), + #"AdjustDates" = Table.TransformColumns(Source,{ + {"Order Date", each Date.AddYears(_, yearsDiff)}, + {"Delivery Date", each Date.AddYears(_, yearsDiff)} + }), + #"Added [NewQuantity]" = Table.AddColumn(#"AdjustDates", "NewQuantity", each + Number.RoundDown( + Number.RandomBetween( + List.Max({1, [Quantity] - ([Quantity] * Randomizer)}), + List.Min({[Quantity], [Quantity] + ([Quantity] * Randomizer)}) + ) + ) + , Int64.Type + ), + #"Added [NewNetPrice]" = Table.AddColumn(#"Added [NewQuantity]", "NewNetPrice", each + Number.Round( + Number.RandomBetween( + List.Max({1, [Net Price] - ([Net Price] * Randomizer)}), + List.Min({[Net Price], [Net Price] + ([Net Price] * Randomizer)}) + ), + 3), + Currency.Type + ), + #"Removed Columns" = Table.RemoveColumns(#"Added [NewNetPrice]",{"Quantity", "Net Price"}), + #"Renamed Columns" = Table.RenameColumns(#"Removed Columns",{ + {"NewQuantity", "Quantity"}, + {"NewNetPrice", "Net Price"} + }) + in + #"Renamed Columns" + lineageTag: 0bd410b3-1a0d-49c7-8952-7a7465778b08 + queryGroup: 'Raw Data' + + annotation PBI_NavigationStepName = Navigation + + annotation PBI_ResultType = Table + +/// Dummy parameter to simulate data from different servers +expression Environment = "TST" meta [IsParameterQuery=true, List={"DEV", "QUAL", "PRD"}, DefaultValue="DEV", Type="Text", IsParameterQueryRequired=true] + lineageTag: 64edd943-1a90-4438-b62f-bb95a9da1510 + + annotation PBI_ResultType = Text + +expression Randomizer = 0.6 meta [IsParameterQuery=true, Type="Number", IsParameterQueryRequired=true] + lineageTag: c92d2f56-fbbe-4162-b5ce-373932bf5cf2 + + annotation PBI_ResultType = Number + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/model.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/model.tmdl new file mode 100644 index 00000000..c47d84af --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/model.tmdl @@ -0,0 +1,42 @@ +model Model + culture: en-US + defaultPowerBIDataSourceVersion: powerBI_V3 + discourageImplicitMeasures + sourceQueryCulture: en-GB + dataAccessOptions + legacyRedirects + returnErrorValuesAsNull + +queryGroup 'Raw Data' + + annotation PBI_QueryGroupOrder = 0 + +annotation PBIDesktopVersion = 2.128.352.0 (24.04) + +annotation __PBI_TimeIntelligenceEnabled = 0 + +annotation PBI_QueryOrder = ["HttpSource","RangeStart","RangeEnd","Environment","Randomizer","Calendar","Sales","Product","Customer","Store","RAW-Product","RAW-Store","RAW-Customer","RAW-Sales","RAW-SalesDateAdjustedAndSalesRandomized","RAW-CurrencyExchange","About"] + +annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES"]} + +annotation __TEdtr = 1 + +annotation PBI_ProTooling = ["DevMode","CalcGroup"] + +ref table Calendar +ref table Sales +ref table Product +ref table Customer +ref table 'Smart Calcs' +ref table 'Dynamic Measure' +ref table Store +ref table About +ref table 'Parameter - Dimension' +ref table 'Parameter - Measure' +ref table 'Time Intelligence' + +ref culture en-US + +ref role 'Stores Cluster 1' +ref role 'Stores Cluster 2' + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/relationships.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/relationships.tmdl new file mode 100644 index 00000000..1849076e --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/relationships.tmdl @@ -0,0 +1,21 @@ +relationship d4e6dc5a-6f46-443d-ab94-4cc0e10323c6 + fromColumn: Sales.CustomerKey + toColumn: Customer.CustomerKey + +relationship 434e79a9-f527-481f-accd-8bc60ed1370e + fromColumn: Sales.'Order Date' + toColumn: Calendar.Date + +relationship 21bd108e-527d-4566-be7d-9e474c858ee0 + isActive: false + fromColumn: Sales.'Delivery Date' + toColumn: Calendar.Date + +relationship bb5c5591-a0ff-4ce4-a62e-6c5f56006368 + fromColumn: Sales.ProductKey + toColumn: Product.ProductKey + +relationship 55a6f513-c6f2-4d1c-b8aa-46edeaeb23f2 + fromColumn: Sales.StoreKey + toColumn: Store.StoreKey + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 1.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 1.tmdl new file mode 100644 index 00000000..46efb698 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 1.tmdl @@ -0,0 +1,7 @@ +role 'Stores Cluster 1' + modelPermission: read + + tablePermission Store = 'Store'[Store Code] IN {"1","2","4"} + + annotation PBI_Id = 3c40ccf098eb4253ad31f8c679e140d3 + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 2.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 2.tmdl new file mode 100644 index 00000000..775a7626 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/roles/Stores Cluster 2.tmdl @@ -0,0 +1,7 @@ +role 'Stores Cluster 2' + modelPermission: read + + tablePermission Store = 'Store'[Store Code] IN {"10","11","15","8"} + + annotation PBI_Id = 160d7a327dfd4ca2996841bfdde3567d + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/About.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/About.tmdl new file mode 100644 index 00000000..c1751207 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/About.tmdl @@ -0,0 +1,48 @@ +/// Table with information about the model. +/// Key/Value representation, with properties like: last refresh; model creator;... +table About + lineageTag: 68907830-e8ac-4c12-98d9-f70711413080 + + column Key + dataType: string + lineageTag: 64fdcf75-33e0-4134-a5b8-677f8fefa7ed + summarizeBy: none + sourceColumn: Key + + annotation SummarizationSetBy = Automatic + + column Value + dataType: string + lineageTag: 41af608f-f46f-4878-be03-184d0cad44cf + summarizeBy: none + sourceColumn: Value + + annotation SummarizationSetBy = Automatic + + column Order + dataType: int64 + formatString: 0 + lineageTag: 93bae8d8-9794-480e-8753-d51693dfa9ad + summarizeBy: none + sourceColumn: Order + + annotation SummarizationSetBy = User + + partition About-77c21240-7751-4575-bf40-8c068bfd01cd = m + mode: import + source = + let + Source = #table({ "Key", "Value" },{ + { "Developed by", "Microsoft" }, + { "Version", "1.0" }, + { "Description", "Sales.pbip" }, + { "Last Refresh", DateTime.ToText(DateTime.LocalNow(), "yyyy-MM-dd HH:mm:ss") } + }), + #"Added Index" = Table.AddIndexColumn(Source, "Order", 1, 1), + #"Changed Type" = Table.TransformColumnTypes(#"Added Index",{{"Key", type text},  {"Value", type text},{"Order", Int64.Type}}), + #"Reordered Columns" = Table.ReorderColumns(#"Changed Type",{"Key", "Value", "Order"}) + in + #"Reordered Columns" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Calendar.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Calendar.tmdl new file mode 100644 index 00000000..f4c15a5b --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Calendar.tmdl @@ -0,0 +1,397 @@ +/// Calendar table +table Calendar + lineageTag: bfa0074c-0a44-4a9c-8bba-c87af778b3d7 + dataCategory: Time + + column Date + dataType: dateTime + isKey + formatString: yyyy-mm-dd + lineageTag: ede68123-9903-412b-8747-d0cb8117ed41 + summarizeBy: none + sourceColumn: Date + + changedProperty = FormatString + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE"]} + + annotation PBI_FormatHint = {"isDateTimeCustom":true} + + column Day + dataType: int64 + formatString: 0 + lineageTag: 15ee1357-a55a-4206-b87f-4537950cefd1 + summarizeBy: none + sourceColumn: Day + + annotation SummarizationSetBy = User + + column 'Week Day' + dataType: string + lineageTag: b249ac49-0964-4b34-ab62-786c1f6d7709 + summarizeBy: none + sourceColumn: Week Day + sortByColumn: 'Week Day (#)' + + changedProperty = SortByColumn + + annotation SummarizationSetBy = Automatic + + column Week + dataType: int64 + formatString: 0 + lineageTag: 0b47fd7c-b526-4a87-8304-50f9da4c79ed + summarizeBy: none + sourceColumn: Week + + annotation SummarizationSetBy = User + + column Month + dataType: string + lineageTag: 229b9d13-a91e-42b0-9d59-bf1aa3568da7 + summarizeBy: none + sourceColumn: Month + sortByColumn: 'Month (#)' + + changedProperty = SortByColumn + + annotation SummarizationSetBy = Automatic + + column Quarter + dataType: int64 + formatString: 0 + lineageTag: 0d2804c4-111e-4544-aaf6-c6a6988e49a1 + summarizeBy: none + sourceColumn: Quarter + + annotation SummarizationSetBy = User + + column Semester + dataType: int64 + formatString: 0 + lineageTag: 1fa538c1-2ae2-4bbf-81ba-ef222e5944ed + summarizeBy: none + sourceColumn: Semester + + annotation SummarizationSetBy = User + + column Year + dataType: int64 + formatString: 0 + lineageTag: e2e54129-d9ea-422d-a07a-747ce9981020 + summarizeBy: none + sourceColumn: Year + + annotation SummarizationSetBy = User + + column 'Week (Year)' + dataType: string + lineageTag: 6977be25-6a48-4eac-846f-85a3c3c271fe + summarizeBy: none + sourceColumn: Week (Year) + sortByColumn: WeekYearId + + changedProperty = SortByColumn + + annotation SummarizationSetBy = Automatic + + column 'Month (Year)' + dataType: string + lineageTag: 557c5e05-696f-4418-85cc-b086c08c3d61 + summarizeBy: none + sourceColumn: Month (Year) + sortByColumn: MonthYearId + + changedProperty = SortByColumn + + annotation SummarizationSetBy = Automatic + + column 'Quarter (Year)' + dataType: string + lineageTag: c0e2abff-7224-4166-b0a9-2fef49976c82 + summarizeBy: none + sourceColumn: Quarter (Year) + sortByColumn: QuarterYearId + + changedProperty = SortByColumn + + annotation SummarizationSetBy = Automatic + + column 'Semester (Year)' + dataType: string + lineageTag: b2b0b830-1b3e-4e9e-b87d-585f4a23deeb + summarizeBy: none + sourceColumn: Semester (Year) + sortByColumn: SemesterYearId + + changedProperty = SortByColumn + + annotation SummarizationSetBy = Automatic + + column WeekYearId + dataType: int64 + isHidden + formatString: 0 + lineageTag: 90bba6d9-d308-40f6-826b-e4d5ca8fda78 + summarizeBy: none + sourceColumn: WeekYearId + + changedProperty = IsHidden + + annotation SummarizationSetBy = User + + column MonthYearId + dataType: int64 + isHidden + formatString: 0 + lineageTag: 81e0ba4a-f927-4152-843a-dfc0e3bd858c + summarizeBy: none + sourceColumn: MonthYearId + + changedProperty = IsHidden + + annotation SummarizationSetBy = User + + column QuarterYearId + dataType: int64 + isHidden + formatString: 0 + lineageTag: 72f566a1-f253-4c96-ba97-47c76d5ce07c + summarizeBy: none + sourceColumn: QuarterYearId + + changedProperty = IsHidden + + annotation SummarizationSetBy = User + + column SemesterYearId + dataType: int64 + isHidden + formatString: 0 + lineageTag: 9c42bf51-c759-439c-b02e-1861c41f56c4 + summarizeBy: none + sourceColumn: SemesterYearId + + changedProperty = IsHidden + + annotation SummarizationSetBy = User + + column 'Week Day (#)' + dataType: int64 + formatString: 0 + lineageTag: 5559c18c-3ef4-4f17-bce2-e0a7cd5e6dea + summarizeBy: none + sourceColumn: Week Day (#) + + annotation SummarizationSetBy = User + + column 'Month (#)' + dataType: int64 + formatString: 0 + lineageTag: 337dbbc4-139c-47a8-bb39-85c6deba31c8 + summarizeBy: none + sourceColumn: Month (#) + + annotation SummarizationSetBy = User + + column 'Day (Relative)' + dataType: int64 + formatString: 0 + lineageTag: 6eee588e-1d25-4994-a6f6-1f94ef196fef + summarizeBy: none + sourceColumn: Day (Relative) + + annotation SummarizationSetBy = User + + column 'Month (Relative)' + dataType: int64 + formatString: 0 + lineageTag: f884824e-2aa5-4a3c-9b59-89e31181a6bb + summarizeBy: none + sourceColumn: Month (Relative) + + annotation SummarizationSetBy = User + + column 'Year (Relative)' + dataType: int64 + formatString: 0 + lineageTag: f9ccaf5a-3f08-42f5-bce9-2f67e78a4173 + summarizeBy: none + sourceColumn: Year (Relative) + + annotation SummarizationSetBy = User + + column 'Work Day' + dataType: string + lineageTag: f58c6828-fdf1-4c2a-9e2f-59454b2f43b5 + summarizeBy: none + sourceColumn: Work Day + + annotation SummarizationSetBy = Automatic + + column DateId + dataType: int64 + isHidden + formatString: 0 + lineageTag: 5e5b06be-4f4f-4624-a922-74de16645764 + summarizeBy: none + sourceColumn: DateId + + changedProperty = IsHidden + + annotation SummarizationSetBy = User + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS","UNNECESSARY_COLUMNS"]} + + column 'Month (Long)' + dataType: string + lineageTag: b27d8a5e-c4bd-4a82-a4c8-bb402aefa873 + summarizeBy: none + sourceColumn: Month (Long) + sortByColumn: 'Month (#)' + + changedProperty = SortByColumn + + annotation SummarizationSetBy = Automatic + + column 'Week (Relative)' + dataType: int64 + formatString: 0 + lineageTag: 9e0d9793-9a33-451a-bc05-24d7878c8964 + summarizeBy: none + sourceColumn: Week (Relative) + + annotation SummarizationSetBy = Automatic + + column 'Week Start Date' + dataType: dateTime + formatString: yyyy-mm-dd + lineageTag: 138a3a63-bb64-469c-a94e-1a65757bd1d1 + summarizeBy: none + sourceColumn: Week Start Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + column 'Week End Date' + dataType: dateTime + formatString: yyyy-mm-dd + lineageTag: c91844bb-d8e2-49b1-97e8-7ff79f35faf6 + summarizeBy: none + sourceColumn: Week End Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + column 'Month Start Date' + dataType: dateTime + formatString: yyyy-mmm + lineageTag: 9398fb3c-c0de-4357-afc0-69df70186f6d + summarizeBy: none + sourceColumn: Month Start Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + column 'Date (Year-Month)' = DATE([Year], [Month (#)],1) + dataType: dateTime + formatString: mmm yyyy + lineageTag: 0e65a7a5-6c15-437d-8333-10653d8d673f + summarizeBy: none + isDataTypeInferred + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"isCustom":true} + + hierarchy Year-Month-Day + lineageTag: d00d0618-e29f-486f-a4d4-dea56417f06b + + level Year + lineageTag: 5d3a5413-d08c-47e5-866e-894c288c29b0 + column: Year + + level Month + lineageTag: 4009bc63-dec6-46e9-84eb-679578b38105 + column: Month + + level Day + lineageTag: 6c697b75-14d3-4471-b6ad-80d79cd35f23 + column: Day + + partition Calendar-c9bc757b-0dad-4b99-8287-18a451a3c5c3 = m + mode: import + source = ``` + let + + P_Today = DateTime.LocalNow(), + P_StartDate = #date(Date.Year(List.Min(#"RAW-SalesDateAdjustedAndSalesRandomized"[Order Date])), 1, 1), + P_EndDate = #date(Date.Year(List.Max(#"RAW-SalesDateAdjustedAndSalesRandomized"[Order Date])), 12, 31), + P_FirstDayOfWeek = 1, + P_IsCarnivalHoliday = true, + P_UseIsoWeek = true, + P_Culture = "en-US", + DayCount = Duration.Days(Duration.From(P_EndDate - P_StartDate)) + 1, + Source = List.Dates(P_StartDate,DayCount,#duration(1,0,0,0)), + TableFromList = Table.FromList(Source, Splitter.SplitByNothing()), + ChangedType = Table.TransformColumnTypes(TableFromList,{{"Column1", type date}}), + RenamedColumns = Table.RenameColumns(ChangedType,{{"Column1", "Date"}}), + InsertId = Table.AddColumn(RenamedColumns, "DateId", each Date.Year([Date])*10000 + Date.Month([Date])*100 +Date.Day([Date])), + InsertYear = Table.AddColumn(InsertId, "Year", each Date.Year([Date])), + InsertQuarter = Table.AddColumn(InsertYear, "Quarter", each Date.QuarterOfYear([Date])), + InsertSemester = Table.AddColumn(InsertQuarter, "Semester", each if [Quarter] < 3 then 1 else 2), + InsertMonth = Table.AddColumn(InsertSemester, "Month (#)", each Date.Month([Date])), + // Simple week + InsertWeekYear = Table.AddColumn(InsertMonth, "WeekYear", each [Year]), + InsertWeek = Table.AddColumn(InsertWeekYear, "Week", each Date.WeekOfYear([Date], P_FirstDayOfWeek )), + // ISO Week + InsertIsoYear = Table.AddColumn(InsertMonth, "WeekYear", each Date.Year(Date.AddDays([Date], 4-(Date.DayOfWeek([Date], Day.Monday) + 1)))), + InsertIsoWeek = Table.AddColumn(InsertIsoYear, "Week", each Duration.Days(Date.AddDays( [Date], 4-(Date.DayOfWeek([Date], Day.Monday) + 1)) - #date([WeekYear], 1 , 7 - Date.DayOfWeek( #date([WeekYear],1,4), Day.Monday)) ) / 7 + 1), + // Choose beetween simple Week and Iso Week according to parameter + ChosenWeek = if P_UseIsoWeek = true then InsertIsoWeek else InsertWeek, + + InsertDay = Table.AddColumn(ChosenWeek, "Day", each Date.Day([Date])), + InsertMonthName = Table.AddColumn(InsertDay, "Month (Long)", each Date.ToText([Date], "MMMM", P_Culture), type text), + InsertShortMonthName = Table.AddColumn(InsertMonthName, "Month", each try(Text.Range([#"Month (Long)"],0,3)) otherwise [#"Month (Long)"]), + InsertCalendarWeek = Table.AddColumn(InsertShortMonthName, "Week (Year)", each "W" & Number.ToText([Week]) & " " & Number.ToText([WeekYear])), + InsertCalendarMonth = Table.AddColumn(InsertCalendarWeek, "Month (Year)", each [#"Month"] & " " & Number.ToText([Year])), + InsertCalendarQtr = Table.AddColumn(InsertCalendarMonth, "Quarter (Year)", each "Q" & Number.ToText([Quarter]) & " " & Number.ToText([Year])), + InsertCalendarSem = Table.AddColumn(InsertCalendarQtr, "Semester (Year)", each "S" & Number.ToText([Semester]) & " " & Number.ToText([Year])), + InsertDayWeek = Table.AddColumn(InsertCalendarSem , "Week Day (#)", each Date.DayOfWeek([Date], P_FirstDayOfWeek ) + 1), + InsertDayName = Table.AddColumn(InsertDayWeek, "Week Day", each Date.ToText([Date], "dddd", P_Culture), type text), + InsertWeekYearId = Table.AddColumn(InsertDayName, "WeekYearId", each [WeekYear] * 100 + [Week]), + InsertMonthYear = Table.AddColumn(InsertWeekYearId, "MonthYearId", each [Year] *100 + [#"Month (#)"]), + InsertWeekStartDate = Table.AddColumn(InsertMonthYear , "Week Start Date", each Date.StartOfWeek([Date], P_FirstDayOfWeek), type date), + InsertWeekEndDate = Table.AddColumn(InsertWeekStartDate , "Week End Date", each Date.EndOfWeek([Date], P_FirstDayOfWeek), type date), + InsertQuarterYear = Table.AddColumn(InsertWeekEndDate, "QuarterYearId", each [Year] * 100 + [Quarter]), + InsertSemesterYear = Table.AddColumn(InsertQuarterYear, "SemesterYearId", each [Year] * 100 + [Semester]), + #"Capitalized Each Word" = Table.TransformColumns(InsertSemesterYear,{{"Month (Long)", Text.Proper}, {"Month", Text.Proper}, {"Month (Year)", Text.Proper}, {"Week Day", Text.Proper}}), + #"Relative (Year)" = Table.AddColumn(#"Capitalized Each Word", "Year (Relative)", each [Year] - Date.Year(P_Today)), + #"Relative (Month)" = Table.AddColumn(#"Relative (Year)", "Month (Relative)", each [#"Year (Relative)"] * 12 + ([#"Month (#)"] - Date.Month(P_Today))), + #"Relative (Week)" = Table.AddColumn(#"Relative (Month)", "Week (Relative)", each Duration.TotalDays(DateTime.Date(Date.StartOfWeek([Date])) - DateTime.Date(Date.StartOfWeek(P_Today))) / 7), + #"Relative (Day)" = Table.AddColumn(#"Relative (Week)", "Day (Relative)", each Duration.TotalDays([Date] - DateTime.Date(P_Today))), + AddedWorkDay =Table.AddColumn(#"Relative (Day)", "Work Day", each if [#"Week Day (#)"] > 5 then "Weekend" else "WorkDay"), + #"Reordered Columns" = Table.ReorderColumns(AddedWorkDay,{"Date", "Day", "Week Day (#)", "Week Day", "Week", "Month (Long)", "Month", "Month (#)", "Quarter", "Semester", "Year", "Week (Year)", "Month (Year)", "Quarter (Year)", "Semester (Year)", "WeekYearId", "MonthYearId", "QuarterYearId", "SemesterYearId", "Day (Relative)", "Week (Relative)", "Month (Relative)", "Year (Relative)", "Work Day"}), + #"Removed Columns" = Table.RemoveColumns(#"Reordered Columns",{"WeekYear"}), + #"Changed Type" = Table.TransformColumnTypes(#"Removed Columns",{{"Day", Int64.Type}, {"Week Day (#)", Int64.Type}, {"Week", Int64.Type}, {"Month (#)", Int64.Type}, {"Quarter", Int64.Type}, {"Semester", Int64.Type}, {"Year", Int64.Type}, {"Week (Year)", type text}, {"Quarter (Year)", type text}, {"Semester (Year)", type text}, {"WeekYearId", Int64.Type}, {"SemesterYearId", Int64.Type}, {"MonthYearId", Int64.Type}, {"QuarterYearId", Int64.Type}, {"Day (Relative)", Int64.Type}, {"Month (Relative)", Int64.Type}, {"Year (Relative)", Int64.Type}, {"Work Day", type text}, {"DateId", Int64.Type}, {"Week (Relative)", Int64.Type}}), + #"Added Custom" = Table.AddColumn(#"Changed Type", "Month Start Date", each #date([Year],[#"Month (#)"],1)), + #"Changed Type1" = Table.TransformColumnTypes(#"Added Custom",{{"Month Start Date", type date}}) + in + #"Changed Type1" + ``` + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["MINIMIZE_POWER_QUERY_TRANSFORMATIONS"]} + + annotation PBI_Id = 9eaa3654-c4d6-42e5-a057-348df1b3f460 + + annotation LinkedQueryName = Calendar + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Customer.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Customer.tmdl new file mode 100644 index 00000000..13a0d400 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Customer.tmdl @@ -0,0 +1,138 @@ +/// Customer data +table Customer + lineageTag: 04227f5a-0aeb-4448-b5c5-dbf3e276da85 + + measure '# Customers' = COUNTROWS('Customer') + formatString: #,##0 + lineageTag: a8dc565a-aa9b-40dc-902c-1ba2596b0977 + + column CustomerKey + dataType: int64 + isKey + formatString: 0 + isAvailableInMdx: false + lineageTag: 901662ed-f0ae-41f6-96b3-4aa68bad1c7a + summarizeBy: none + sourceColumn: CustomerKey + + annotation SummarizationSetBy = Automatic + + column Gender + dataType: string + lineageTag: 7e0ce5eb-3e63-4870-b30a-032f5186375d + summarizeBy: none + sourceColumn: Gender + + annotation SummarizationSetBy = Automatic + + column Customer + dataType: string + lineageTag: 8845f8b5-b069-41a0-8af8-12e402b113e9 + isDefaultLabel + summarizeBy: none + sourceColumn: Customer + + annotation SummarizationSetBy = Automatic + + column Address + dataType: string + lineageTag: adbeb074-7c67-40ee-be2d-d46775ce5e17 + summarizeBy: none + sourceColumn: Address + + annotation SummarizationSetBy = Automatic + + column City + dataType: string + lineageTag: a791eb8c-7fab-418d-844a-f8b094453037 + dataCategory: City + summarizeBy: none + sourceColumn: City + + annotation SummarizationSetBy = Automatic + + column 'State Code' + dataType: string + lineageTag: 29de3d16-fffa-4f61-8c39-c1a2a36d737a + dataCategory: StateOrProvince + summarizeBy: none + sourceColumn: State Code + + annotation SummarizationSetBy = Automatic + + column State + dataType: string + lineageTag: 923b4cc1-6137-4bb4-bcb2-3a90c5f76c5e + dataCategory: StateOrProvince + summarizeBy: none + sourceColumn: State + + annotation SummarizationSetBy = Automatic + + column 'Zip Code' + dataType: string + lineageTag: 439ce0ee-437e-4770-90c4-c4d53ebfb35b + dataCategory: PostalCode + summarizeBy: none + sourceColumn: Zip Code + + annotation SummarizationSetBy = Automatic + + column 'Country Code' + dataType: string + lineageTag: f3eea279-4255-446b-9f83-f40f66abf6d1 + dataCategory: Country + summarizeBy: none + sourceColumn: Country Code + + annotation SummarizationSetBy = Automatic + + column Country + dataType: string + lineageTag: 99af44ae-86e9-4b75-98a3-e4b6e53fe806 + dataCategory: Country + summarizeBy: none + sourceColumn: Country + + annotation SummarizationSetBy = Automatic + + column Continent + dataType: string + lineageTag: 53b840d2-962c-44c4-8733-6cc003fb9a83 + dataCategory: Continent + summarizeBy: none + sourceColumn: Continent + + annotation SummarizationSetBy = Automatic + + column Birthday + dataType: dateTime + formatString: Long Date + lineageTag: afa5e191-c690-45c1-a725-41c9d3ca9434 + summarizeBy: none + sourceColumn: Birthday + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + column Age + dataType: int64 + formatString: 0 + lineageTag: 5c487309-c061-45ed-aa74-aba4d20bde3b + summarizeBy: none + sourceColumn: Age + + annotation SummarizationSetBy = Automatic + + partition Customer-3757b886-e26c-4cec-a550-cdeea37b94d4 = m + mode: import + source = + let + Source = #"RAW-Customer", + #"Renamed Columns" = Table.RenameColumns(Source,{{"Name", "Customer"}}) + in + #"Renamed Columns" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Dynamic Measure.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Dynamic Measure.tmdl new file mode 100644 index 00000000..d1c60760 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Dynamic Measure.tmdl @@ -0,0 +1,164 @@ +/// Dynamic Measure table +/// Useful to explore measure "as a dimension" +table 'Dynamic Measure' + lineageTag: 208f785d-03c1-41ca-b33a-c56c36916caa + + measure Value = ``` + + IF ( + HASONEVALUE ( 'Dynamic Measure'[Code] ), + var measureCode = SELECTEDVALUE('Dynamic Measure'[Code]) + return SWITCH ( + measureCode + ,1, [Sales Amount] + ,2, [Sales Amount (% Δ LY)] + ,3, [# Customers (with Sales)] + ,4, [Sales Qty] + ,5, [Margin] + ,BLANK () + ) + ) + ``` + lineageTag: 1f748dd6-758e-4445-bf6c-dab17d61d2ee + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["PROVIDE_FORMAT_STRING_FOR_MEASURES","INTEGER_FORMATTING"]} + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + measure 'Value (ly)' = CALCULATE ( [Value], SAMEPERIODLASTYEAR('Calendar'[Date]) ) + lineageTag: 59781612-6890-4665-a1b2-aa25324fe896 + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["PROVIDE_FORMAT_STRING_FOR_MEASURES","INTEGER_FORMATTING"]} + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + measure 'Value (ytd)' = CALCULATE([Value], DATESYTD('Calendar'[Date])) + lineageTag: b01909c9-718c-48d2-98af-482b39bf2272 + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["PROVIDE_FORMAT_STRING_FOR_MEASURES","INTEGER_FORMATTING"]} + + measure 'Value Avg per Month' = ``` + + AVERAGEX(VALUES('Calendar'[Month (Year)]), [Value]) + ``` + lineageTag: ba87b819-2147-443b-aaeb-8ae1729c6550 + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["PROVIDE_FORMAT_STRING_FOR_MEASURES","INTEGER_FORMATTING"]} + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + measure 'Value Daily Max' = MAXX(VALUES('Calendar'[Date]), [Value]) + lineageTag: d8fbae5f-a0c0-4b2e-85e9-1e045d7510e0 + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["PROVIDE_FORMAT_STRING_FOR_MEASURES","INTEGER_FORMATTING"]} + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + measure 'Value % (Δ ly)' = + + var ly =[Value (ly)] + return + DIVIDE( + [Value]- ly, + ly + ) + formatString: 0.00%;-0.00%;0.00% + lineageTag: 897fe824-eb94-4ec9-996e-26238f4c2b23 + + changedProperty = FormatString + + measure 'Value Normalized (by date)' = ``` + + VAR DetailValue = [Value] + return if (DetailValue, + + //VAR MinOfGroup = MINX(ALLSELECTED('Calendar'[Month (Year)], 'Calendar'[MonthYearId]), [Value]) + //VAR MaxOfGroup = MAXX(ALLSELECTED('Calendar'[Month (Year)], 'Calendar'[MonthYearId]), [Value]) + VAR MinOfGroup = MINX(ALLSELECTED('Calendar'), [Value]) + VAR MaxOfGroup = MAXX(ALLSELECTED('Calendar'), [Value]) + RETURN DIVIDE(DetailValue - MinOfGroup, MaxOfGroup - MinOfGroup) + ) + ``` + lineageTag: 15549406-fb29-46e8-9094-25add2c74995 + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["PROVIDE_FORMAT_STRING_FOR_MEASURES","INTEGER_FORMATTING"]} + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column Code + dataType: int64 + formatString: 0 + lineageTag: a5db8d3b-70af-483d-bcab-5c0fcc7478c2 + summarizeBy: none + isNameInferred + isDataTypeInferred + sourceColumn: [Code] + + annotation SummarizationSetBy = User + + column Order + dataType: int64 + formatString: 0 + lineageTag: c7824b9c-021c-4bff-b363-c04b3cb2c681 + summarizeBy: none + isNameInferred + isDataTypeInferred + sourceColumn: [Order] + + annotation SummarizationSetBy = User + + column Measure + dataType: string + lineageTag: d8e3ae78-fda2-49e6-b8cb-b6257e927261 + summarizeBy: none + isNameInferred + isDataTypeInferred + sourceColumn: [Measure] + + annotation SummarizationSetBy = Automatic + + column Area + dataType: string + lineageTag: 364ae39d-73bd-4527-91e4-0e649a23d8a7 + summarizeBy: none + isNameInferred + isDataTypeInferred + sourceColumn: [Area] + + annotation SummarizationSetBy = Automatic + + column Format + dataType: string + lineageTag: ec17695c-dc59-4674-bdaa-bbdeb4131593 + summarizeBy: none + isNameInferred + isDataTypeInferred + sourceColumn: [Format] + + annotation SummarizationSetBy = Automatic + + partition 'Dynamic Measure-e40351ed-a523-4ff4-9185-7834b3fbb8e8' = calculated + mode: import + source = ``` + + DATATABLE ( + "Code", INTEGER, + "Order", INTEGER, + "Measure", STRING, + "Area", STRING, + "Format", STRING, + { + { 1, 1, "Sales Amount", "Sales", "\$#,0.00;(\$#,0.00);\$#,0.00" }, + { 2, 2, "Sales Growth vs LY", "Sales", "0.000%"}, + { 3, 4, "# Customers", "Marketing", "#,#" }, + { 4, 2, "Sales Qty", "Sales", "#,#" }, + { 5, 2, "Sales Margin", "Sales", "\$#,0.00;(\$#,0.00);\$#,0.00" } + } + ) + + ``` + + annotation PBI_Id = 4d2f308d1af14ea3accf3c458621a9d7 + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["REDUCE_USAGE_OF_CALCULATED_TABLES","ENSURE_TABLES_HAVE_RELATIONSHIPS"]} + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Dimension.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Dimension.tmdl new file mode 100644 index 00000000..d0c48eb1 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Dimension.tmdl @@ -0,0 +1,55 @@ +table 'Parameter - Dimension' + lineageTag: 29294de5-e93a-47f4-bd78-26ec8efe7786 + + column 'Parameter - Dimension' + dataType: string + lineageTag: f29057e2-1184-48f8-b6c5-08f85cfd5ec1 + summarizeBy: none + isDataTypeInferred + sourceColumn: [Value1] + sortByColumn: 'Parameter - Dimension Order' + + relatedColumnDetails + groupByColumn: 'Parameter - Dimension Fields' + + annotation SummarizationSetBy = Automatic + + column 'Parameter - Dimension Fields' + dataType: string + isHidden + lineageTag: dc604c44-bcb1-44cd-acb7-55201a651d63 + summarizeBy: none + isDataTypeInferred + sourceColumn: [Value2] + sortByColumn: 'Parameter - Dimension Order' + + extendedProperty ParameterMetadata = + { + "version": 3, + "kind": 2 + } + + annotation SummarizationSetBy = Automatic + + column 'Parameter - Dimension Order' + dataType: int64 + isHidden + formatString: 0 + lineageTag: 2102f8c8-0503-42f5-ac07-b1a606810a4a + summarizeBy: sum + isDataTypeInferred + sourceColumn: [Value3] + + annotation SummarizationSetBy = Automatic + + partition 'Parameter - Dimension' = calculated + mode: import + source = + { + ("Customer", NAMEOF('Customer'[Customer]), 0), + ("Product", NAMEOF('Product'[Product]), 1), + ("Store", NAMEOF('Store'[Store]), 2) + } + + annotation PBI_Id = fb0b744d500c4fe187fc782efa6004bb + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Measure.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Measure.tmdl new file mode 100644 index 00000000..16f612de --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Parameter - Measure.tmdl @@ -0,0 +1,58 @@ +table 'Parameter - Measure' + lineageTag: eee26640-bfec-44ed-b1e7-d56562bc25ed + + column 'Parameter - Measure' + dataType: string + lineageTag: f2f4b00e-fa13-46c5-9726-16717f325e26 + summarizeBy: none + isDataTypeInferred + sourceColumn: [Value1] + sortByColumn: 'Parameter - Measure Order' + + relatedColumnDetails + groupByColumn: 'Parameter - Measure Fields' + + annotation SummarizationSetBy = Automatic + + column 'Parameter - Measure Fields' + dataType: string + isHidden + lineageTag: 4787b049-3037-4007-9719-bfeed93a7cff + summarizeBy: none + isDataTypeInferred + sourceColumn: [Value2] + sortByColumn: 'Parameter - Measure Order' + + extendedProperty ParameterMetadata = + { + "version": 3, + "kind": 2 + } + + annotation SummarizationSetBy = Automatic + + column 'Parameter - Measure Order' + dataType: int64 + isHidden + formatString: 0 + lineageTag: 5898d688-d0af-4285-9d33-3a41d2401e4b + summarizeBy: sum + isDataTypeInferred + sourceColumn: [Value3] + + annotation SummarizationSetBy = Automatic + + partition 'Parameter - Measure' = calculated + mode: import + source = + { + ("# Sales", NAMEOF('Sales'[# Sales]), 0), + ("# Products (with Sales)", NAMEOF('Sales'[# Products (with Sales)]), 1), + ("# Customers (with Sales)", NAMEOF('Sales'[# Customers (with Sales)]), 2), + ("Margin", NAMEOF('Sales'[Margin]), 3), + ("Sales Amount", NAMEOF('Sales'[Sales Amount]), 4), + ("Sales Qty", NAMEOF('Sales'[Sales Qty]), 5) + } + + annotation PBI_Id = 1a6e47ba0137472192b990cc0ff130aa + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Product.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Product.tmdl new file mode 100644 index 00000000..ffaa9b5e --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Product.tmdl @@ -0,0 +1,156 @@ +/// Product Catalog +table Product + lineageTag: e9374b9a-faee-4f9e-b2e7-d9aafb9d6a91 + + measure '# Products' = COUNTROWS('Product') + formatString: #,##0 + lineageTag: 1f8f1a2a-06b6-4989-8af7-212719cf3617 + + column Product + dataType: string + lineageTag: da435585-1f9a-44bd-ba2c-34c98f298cfc + isDefaultLabel + summarizeBy: none + sourceColumn: Product + + annotation SummarizationSetBy = Automatic + + column ProductKey + dataType: int64 + isKey + formatString: 0 + isAvailableInMdx: false + lineageTag: 4184d53e-cd2d-4cbe-b8cb-04c72a750bc4 + summarizeBy: none + sourceColumn: ProductKey + + annotation SummarizationSetBy = Automatic + + column 'Product Code' + dataType: string + lineageTag: e9d204ad-76d8-4db9-9d1a-b9c07a4b50b2 + summarizeBy: none + sourceColumn: Product Code + + annotation SummarizationSetBy = Automatic + + column Manufacturer + dataType: string + lineageTag: 59e45f50-f68d-44c3-becd-70ccd5a7eb7d + summarizeBy: none + sourceColumn: Manufacturer + + annotation SummarizationSetBy = Automatic + + column Brand + dataType: string + lineageTag: a71b235d-8f7e-4678-85a3-96a78d64bf87 + summarizeBy: none + sourceColumn: Brand + + annotation SummarizationSetBy = Automatic + + column Color + dataType: string + lineageTag: 7054b4d0-6d93-4c96-be74-800d02d96e43 + summarizeBy: none + sourceColumn: Color + + annotation SummarizationSetBy = Automatic + + column 'Weight Unit Measure' + dataType: string + lineageTag: 78fcf7c4-2b5d-45b0-abf9-6ee3b3aa255b + summarizeBy: none + sourceColumn: Weight Unit Measure + + annotation SummarizationSetBy = Automatic + + column Weight + dataType: decimal + lineageTag: a6299b36-bd05-4b41-8493-e45359af237b + summarizeBy: none + sourceColumn: Weight + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column 'Unit Cost' + dataType: decimal + lineageTag: f89fa3e3-061d-4269-8cd3-aa6ce2a464d2 + summarizeBy: none + sourceColumn: Unit Cost + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column 'Unit Price' + dataType: decimal + lineageTag: ef300027-e4eb-4c7d-9770-ab8f6dab6b15 + summarizeBy: none + sourceColumn: Unit Price + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column 'Subcategory Code' + dataType: string + lineageTag: 7cd08eb9-2cad-4263-ae88-8c5121a68b7e + summarizeBy: none + sourceColumn: Subcategory Code + + annotation SummarizationSetBy = Automatic + + column Subcategory + dataType: string + lineageTag: 0a208c62-4bdd-4873-af18-ebc286c5b3bb + summarizeBy: none + sourceColumn: Subcategory + + annotation SummarizationSetBy = Automatic + + column 'Category Code' + dataType: string + lineageTag: c0fc218a-5a06-4757-9172-2d303a67f3ff + summarizeBy: none + sourceColumn: Category Code + + annotation SummarizationSetBy = Automatic + + column Category + dataType: string + lineageTag: 0f4b99cc-fdb6-4f04-b7d9-bbdcf7b2c601 + summarizeBy: none + sourceColumn: Category + + annotation SummarizationSetBy = Automatic + + hierarchy 'Product Hierarchy' + lineageTag: 89345cc9-e735-4d62-8caf-e494a6314e93 + + level Category + lineageTag: 9ff3052d-e0de-44e8-85c3-85b8cc978936 + column: Category + + level Subcategory + lineageTag: 647503e7-1d2b-4e0a-bc36-1ce6bc3d81ca + column: Subcategory + + level Product + lineageTag: 85ba527d-a9e2-4f3d-85d9-447600445bc3 + column: Product + + partition Product-171f48b3-e0ea-4ea3-b9a0-c8c673eb0648 = m + mode: import + source = + let + Source = #"RAW-Product", + #"Renamed Columns" = Table.RenameColumns(Source,{{"Product Name", "Product"}}) + in + #"Renamed Columns" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Sales.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Sales.tmdl new file mode 100644 index 00000000..e46946b8 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Sales.tmdl @@ -0,0 +1,372 @@ +/// Sales table for year over year analysis +table Sales + lineageTag: 97143e5b-7736-4fcb-8042-26b92b1f5684 + + measure '# Customers (with Sales)' = DISTINCTCOUNT('Sales'[CustomerKey]) + formatString: #,##0 + lineageTag: 4ec872f0-a42d-4aa9-bf6b-1ca7c4916336 + + measure '# Products (with Sales)' = DISTINCTCOUNT('Sales'[ProductKey]) + formatString: #,##0 + lineageTag: b11c3386-30b8-4303-8ddb-ac0d2fa7660d + + changedProperty = FormatString + + measure 'Sales Qty' = sum('Sales'[Quantity]) + formatString: #,##0 + lineageTag: c2ff8d96-2f03-4005-84df-91458625b73b + + measure 'Sales Amount' = SUMX('Sales', 'Sales'[Quantity] * 'Sales'[Net Price]) + formatString: $ #,##0 + lineageTag: a8e95485-02a2-4525-b02a-b2418fbdbe4c + + changedProperty = FormatString + + annotation PBI_FormatHint = {"isCustom":true} + + measure 'Sales Amount (Δ LY)' = [Sales Amount] - [Sales Amount (LY)] + formatString: $ #,##0 + lineageTag: d2724187-f1de-4a90-84bc-fc96aab3194b + + /// Sales Amount Last Year considering a full month + /// + measure 'Sales Amount (LY)' = CALCULATE([Sales Amount], SAMEPERIODLASTYEAR('Calendar'[Date])) + formatString: \$#,0;(\$#,0);\$#,0 + lineageTag: 3fa889ae-64b9-4a6e-b0e6-c81c90fd32bf + + changedProperty = FormatString + + annotation PBI_FormatHint = {"currencyCulture":"en-US"} + + measure 'Sales Amount (YTD, LY)' = CALCULATE([Sales Amount (YTD)], SAMEPERIODLASTYEAR('Calendar'[Date])) + formatString: $ #,##0 + lineageTag: 6868b958-3e8b-4e0e-ae86-8a88e4651834 + + measure 'Sales Amount (YTD)' = TOTALYTD([Sales Amount],'Calendar'[Date]) + formatString: $ #,##0 + lineageTag: aafaacd7-ff25-4a69-b8e7-f29fb02c5351 + + measure 'Sales Amount Avg per Day' = AVERAGEX(VALUES('Calendar'[Date]), [Sales Amount]) + formatString: $ #,##0 + lineageTag: 65842ce7-7176-4106-868e-2e83aaa62b4c + + measure 'Sales Amount (% Δ LY)' = + var ly =[Sales Amount (LY)] + return + DIVIDE( + [Sales Amount]- ly, + ly + ) + formatString: #,##0.00 % + lineageTag: f1a3a032-8cea-4b06-b85a-f6f4e03e1d9f + + measure Margin = ``` + SUMX ( + Sales, + Sales[Quantity] + * ( Sales[Net Price] - Sales[Unit Cost] ) + ) + ``` + formatString: $ #,##0 + lineageTag: d22a262b-b776-4ccd-b9bd-7ef9c90eba51 + + kpi + targetExpression = ``` + [Margin % Overall] + + ``` + statusExpression = + VAR MarginPercentage = [Margin %] + VAR MarginTolerance = 0.02 + VAR MarginGoal = 0.3 + RETURN + IF ( + NOT ISBLANK ( MarginPercentage ), + SWITCH ( + TRUE, + MarginPercentage < MarginGoal - MarginTolerance, -1, -- Negative + MarginPercentage > MarginGoal + MarginTolerance, 1, -- Positive + 0 + ) + ) + trendExpression = + -- DAX code for Trend Expression + VAR MarginPerc = [Margin %] + VAR PrevMarginPerc = + CALCULATE ( + [Margin %], + PREVIOUSYEAR( 'Calendar'[Date] ) + ) + RETURN + IF ( + NOT ISBLANK ( MarginPerc ) && NOT ISBLANK ( PrevMarginPerc ), + SWITCH ( + TRUE, + MarginPerc > PrevMarginPerc, 1, -- Positive + MarginPerc < PrevMarginPerc, -1, -- Negative + 0 + ) + ) + + changedProperty = FormatString + + annotation PBI_FormatHint = {"isCustom":true} + + measure 'Margin (ly)' = ``` + CALCULATE([Margin], SAMEPERIODLASTYEAR('Calendar'[Date])) + + ``` + formatString: $ #,##0 + lineageTag: bdf081eb-1952-439f-96b1-a27e1a13f1a3 + + measure '# Sales' = COUNTROWS('Sales') + formatString: #,##0 + lineageTag: 67791bd1-5f33-4a29-a9ac-be0ad2453fcf + + changedProperty = FormatString + + changedProperty = IsHidden + + measure 'Sales Qty by Delivery Date' = ``` + CALCULATE([Sales Qty], USERELATIONSHIP('Sales'[Delivery Date],'Calendar'[Date])) + + + ``` + formatString: #,##0 + lineageTag: c85c43d0-a2e1-4d90-a8a0-4009499b6eea + + /// 12 Month moving average sales calculation + /// + measure 'Sales Amount (12M average)' = + + VAR v_selDate = + MAX ( 'Calendar'[Date] ) + VAR v_period = + DATESINPERIOD ( 'Calendar'[Date], v_selDate, -12, MONTH ) + VAR v_result = + CALCULATE ( AVERAGEX ( VALUES ( 'Calendar'[Date] ), [Sales Amount] ), v_period ) + VAR v_firstDate = + MINX ( v_period, 'Calendar'[Date] ) + VAR v_lastDateSales = + MAX ( Sales[Order Date] ) + RETURN + IF ( v_firstDate <= v_lastDateSales, v_result ) + formatString: $ #,##0 + lineageTag: 7fd60f7e-287e-46c3-a745-24c4507bc77b + + annotation PBI_FormatHint = {"isCustom":true} + + measure 'Sales Amount (6M average)' = + + VAR v_selDate = + MAX ( 'Calendar'[Date] ) + VAR v_period = + DATESINPERIOD ( 'Calendar'[Date], v_selDate, -6, MONTH ) + VAR v_result = + CALCULATE ( AVERAGEX ( VALUES ( 'Calendar'[Date] ), [Sales Amount] ), v_period ) + VAR v_firstDate = + MINX ( v_period, 'Calendar'[Date] ) + VAR v_lastDateSales = + MAX ( Sales[Order Date] ) + RETURN + IF ( v_firstDate <= v_lastDateSales, v_result ) + formatString: $ #,##0 + lineageTag: 9a48bea0-e5fb-40fa-9e81-f61288e31a02 + + measure 'Margin %' = DIVIDE ( [Margin], [Sales Amount] ) + formatString: #,##0.00 % + lineageTag: 91a7a901-c646-41fe-a1ed-6e82b421d140 + + measure Cost = SUMX ( Sales, Sales[Quantity] * Sales[Unit Cost] ) + formatString: $ #,##0 + lineageTag: 3e5cb67e-a411-476f-ad34-7ec10dfd084d + + measure 'Margin % Overall' = ``` + ROUND ( CALCULATE( [Margin %], REMOVEFILTERS () ), 2 ) + + ``` + formatString: #,##0.00 % + lineageTag: b2155351-e104-4bae-941b-3e651804cee5 + + column Quantity + dataType: int64 + isHidden + formatString: 0 + isAvailableInMdx: false + lineageTag: 0aaf711e-9b42-4cfb-9ab6-bee80e843a12 + summarizeBy: none + sourceColumn: Quantity + + changedProperty = IsHidden + + annotation SummarizationSetBy = User + + column 'Order Number' + dataType: int64 + formatString: 0 + lineageTag: e2e629f1-f1fb-4444-a627-b121d77fdc06 + summarizeBy: none + sourceColumn: Order Number + + annotation SummarizationSetBy = Automatic + + column 'Line Number' + dataType: int64 + formatString: 0 + lineageTag: d4046972-90a5-46b2-badb-709e834b99af + summarizeBy: none + sourceColumn: Line Number + + annotation SummarizationSetBy = Automatic + + column 'Order Date' + dataType: dateTime + formatString: Long Date + lineageTag: d2d074d6-be1f-4dfd-b826-cd99fe83bc3a + summarizeBy: none + sourceColumn: Order Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["HIDE_FOREIGN_KEYS","RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE"]} + + column 'Delivery Date' + dataType: dateTime + formatString: Long Date + lineageTag: 1056fc0f-d1e5-4872-a39b-25603dba5cf5 + summarizeBy: none + sourceColumn: Delivery Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["HIDE_FOREIGN_KEYS","RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE"]} + + column CustomerKey + dataType: int64 + isHidden + formatString: 0 + isAvailableInMdx: false + lineageTag: 4de77f33-318d-4006-85db-580cb119fc6a + summarizeBy: none + sourceColumn: CustomerKey + + changedProperty = IsHidden + + annotation SummarizationSetBy = Automatic + + column StoreKey + dataType: int64 + isHidden + formatString: 0 + isAvailableInMdx: false + lineageTag: cf26d73c-10d7-40ac-83a3-c91c04e948d8 + summarizeBy: none + sourceColumn: StoreKey + + changedProperty = IsHidden + + annotation SummarizationSetBy = Automatic + + column ProductKey + dataType: int64 + isHidden + formatString: 0 + isAvailableInMdx: false + lineageTag: 595525b1-f5ca-442e-a226-0cc478b823c0 + summarizeBy: none + sourceColumn: ProductKey + + changedProperty = IsHidden + + annotation SummarizationSetBy = Automatic + + column 'Net Price' + dataType: decimal + isHidden + isAvailableInMdx: false + lineageTag: d4df8046-8db3-4910-8759-33b904a0cac6 + summarizeBy: sum + sourceColumn: Net Price + + changedProperty = IsHidden + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column 'Unit Cost' + dataType: decimal + isHidden + isAvailableInMdx: false + lineageTag: 03a43676-2ce4-4678-a4a7-0ee8f4e00b17 + summarizeBy: sum + sourceColumn: Unit Cost + + changedProperty = IsHidden + + annotation SummarizationSetBy = Automatic + + annotation BestPracticeAnalyzer_IgnoreRules = {"RuleIDs":["REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES"]} + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column 'Currency Code' + dataType: string + lineageTag: a75ba79f-22a3-4bfd-9b52-80e5d73247a9 + summarizeBy: none + sourceColumn: Currency Code + + annotation SummarizationSetBy = Automatic + + column 'Exchange Rate' + dataType: decimal + lineageTag: 0b3f913f-1587-4280-a8bb-21ab7d661086 + summarizeBy: none + sourceColumn: Exchange Rate + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column Environment + dataType: string + lineageTag: 05fb6ae8-19b4-40c5-81b2-e40a6edd8692 + summarizeBy: none + sourceColumn: Environment + + annotation SummarizationSetBy = Automatic + + column Time + dataType: dateTime + formatString: Long Time + lineageTag: 00586e3b-f4b1-4314-9cff-0a9695c99eb2 + summarizeBy: none + sourceColumn: Time + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Time + + partition Sales-ddb4c40b-46fd-49ea-9a19-16e7e640a21a = m + mode: import + source = + let + Source = #"RAW-SalesDateAdjustedAndSalesRandomized", + #"Removed Columns" = Table.RemoveColumns(Source,{"Unit Price"}), + #"Changed Type1" = Table.TransformColumnTypes(#"Removed Columns",{{"Delivery Date", type datetime}, {"Order Date", type datetime}}), + #"Filtered Rows" = Table.SelectRows(#"Changed Type1", each [Order Date] >= RangeStart and [Order Date] <= RangeEnd), + #"Changed Type2" = Table.TransformColumnTypes(#"Filtered Rows",{{"Delivery Date", type date}, {"Order Date", type date}}), + #"Added Custom" = Table.AddColumn(#"Changed Type2", "Environment", each Environment, type text) + in + #"Added Custom" + + annotation PBI_Id = 975ddcc4-65e2-4eb0-9c9c-2c5ef0586f0e + + annotation LinkedQueryName = Sales + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Smart Calcs.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Smart Calcs.tmdl new file mode 100644 index 00000000..4d38f750 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Smart Calcs.tmdl @@ -0,0 +1,96 @@ +table 'Smart Calcs' + lineageTag: fa79b28a-6a9c-43b1-a775-099dabcd4428 + + calculationGroup + + calculationItem Normalize = ``` + VAR DetailValue = SELECTEDMEASURE() + + return if (DetailValue, + + VAR MinOfGroup = MINX(ALLSELECTED('Calendar'), SELECTEDMEASURE()) + VAR MaxOfGroup = MAXX(ALLSELECTED('Calendar'), SELECTEDMEASURE()) + + RETURN DIVIDE(DetailValue - MinOfGroup, MaxOfGroup - MinOfGroup) + ) + ``` + + formatStringDefinition = "0.0" + + calculationItem Randomize = IFERROR(SELECTEDMEASURE() * RAND(), SELECTEDMEASURE()) + + calculationItem 'Label - ▲ LY' = ``` + var vValue = SELECTEDMEASURE() + var vValueLY = CALCULATE(SELECTEDMEASURE(), SAMEPERIODLASTYEAR('Calendar'[Date])) + var vGrowth = DIVIDE(vValue - vValueLY, vValueLY) + var vFormat = SELECTEDMEASUREFORMATSTRING() + + return + FORMAT(vValue, vFormat) + & IF (ISBLANK(vGrowth) + , BLANK() + , " | " + & IF (vGrowth >= 0, "▲" , "▼") & FORMAT(vGrowth, "0%") + ) + + ``` + + formatStringDefinition = SELECTEDMEASUREFORMATSTRING() + + calculationItem 'Dynamic Measure - Apply Format' = SELECTEDMEASURE() + + formatStringDefinition = ``` + + IF ( + // Only do this for the 'Dynamic Measure' Measures + ISSELECTEDMEASURE ( [Value], [Value (ly)], [Value (ytd)], [Value Avg per Month] ), + VAR measureCode = + SELECTEDVALUE ( 'Dynamic Measure'[Code] ) + VAR measureFormat = + IF ( + measureCode <> BLANK (), + LOOKUPVALUE ( 'Dynamic Measure'[Format], 'Dynamic Measure'[Code], measureCode ) + ) + RETURN + IF ( measureFormat <> BLANK (), measureFormat, SELECTEDMEASUREFORMATSTRING () ) + + , SELECTEDMEASUREFORMATSTRING () + ) + ``` + + calculationItem 'Label - ▲ LM' = ``` + var vValue = SELECTEDMEASURE() + var vValueLM = CALCULATE(SELECTEDMEASURE(), PREVIOUSMONTH('Calendar'[Date])) + var vGrowth = DIVIDE(vValue - vValueLM, vValueLM) + var vFormat = SELECTEDMEASUREFORMATSTRING() + + return + FORMAT(vValue, vFormat) + & IF (ISBLANK(vGrowth) + , BLANK() + , " | " + & IF (vGrowth >= 0, "▲" , "▼") & FORMAT(vGrowth, "0%") + ) + + ``` + + formatStringDefinition = SELECTEDMEASUREFORMATSTRING() + + column 'Smart Calc' + dataType: string + lineageTag: 005e8b9e-378a-421a-905c-f21dad946ceb + summarizeBy: none + sourceColumn: Name + sortByColumn: Ordinal + + annotation SummarizationSetBy = Automatic + + column Ordinal + dataType: int64 + isHidden + lineageTag: 2532b358-06e7-449d-a944-810f51fff7a5 + summarizeBy: none + sourceColumn: Ordinal + + annotation SummarizationSetBy = User + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Store.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Store.tmdl new file mode 100644 index 00000000..b188c936 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Store.tmdl @@ -0,0 +1,105 @@ +/// Store metadata +table Store + lineageTag: 54d5f884-12db-4e03-a120-08cd725db2c4 + + measure '# Stores' = COUNTROWS('Store') + formatString: #,##0 + lineageTag: 868df9c8-f579-47d1-a776-3d29121df7c7 + + changedProperty = FormatString + + column StoreKey + dataType: int64 + isKey + formatString: 0 + isAvailableInMdx: false + lineageTag: b63bc7b8-266a-4424-9676-c3e68501b2ec + summarizeBy: none + sourceColumn: StoreKey + + annotation SummarizationSetBy = Automatic + + column 'Store Code' + dataType: string + lineageTag: 307e3348-2132-4db3-b76a-771ac4561ef5 + summarizeBy: none + sourceColumn: Store Code + + annotation SummarizationSetBy = Automatic + + column Country + dataType: string + lineageTag: 7564fe29-ad01-43e0-ba93-2e1634e1a9b3 + dataCategory: Country + summarizeBy: none + sourceColumn: Country + + annotation SummarizationSetBy = Automatic + + column State + dataType: string + lineageTag: f471c9c0-1924-46e4-99d8-59ccf0f64cba + summarizeBy: none + sourceColumn: State + + annotation SummarizationSetBy = Automatic + + column Store + dataType: string + lineageTag: 7bee915f-7eb7-4dbc-a16f-e796f410b3f5 + isDefaultLabel + summarizeBy: none + sourceColumn: Store + + annotation SummarizationSetBy = Automatic + + column 'Square Meters' + dataType: int64 + formatString: 0 + lineageTag: 1596588a-acc4-41c4-a692-310fd28994bb + summarizeBy: none + sourceColumn: Square Meters + + annotation SummarizationSetBy = Automatic + + column 'Open Date' + dataType: dateTime + formatString: Long Date + lineageTag: 66cf5168-4add-4a05-b2c8-0380a85b0539 + summarizeBy: none + sourceColumn: Open Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + column 'Close Date' + dataType: dateTime + formatString: Long Date + lineageTag: e627ea17-3b62-46f2-a2f4-549dc98beb06 + summarizeBy: none + sourceColumn: Close Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + column Status + dataType: string + lineageTag: 93122991-3d6a-413c-ad26-3610fa90013b + summarizeBy: none + sourceColumn: Status + + annotation SummarizationSetBy = Automatic + + partition Store-c0e5ba98-f95a-4712-91ec-71c7dc35e177 = m + mode: import + source = + let + Source = #"RAW-Store", + #"Renamed Columns" = Table.RenameColumns(Source,{{"Name", "Store"}}) + in + #"Renamed Columns" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Time Intelligence.tmdl b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Time Intelligence.tmdl new file mode 100644 index 00000000..39769787 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-analysis-services-sales/definition/tables/Time Intelligence.tmdl @@ -0,0 +1,75 @@ +table 'Time Intelligence' + lineageTag: fdbc6d0f-8c72-46c3-8235-34dac0ad3009 + + calculationGroup + precedence: 1 + + calculationItem YTD = + CALCULATE ( + SELECTEDMEASURE (), + DATESYTD ( 'Calendar'[Date] ) + ) + + calculationItem QTD = + + CALCULATE ( + SELECTEDMEASURE (), + DATESQTD ( 'Calendar'[Date] ) + ) + + calculationItem MTD = + CALCULATE ( + SELECTEDMEASURE (), + DATESMTD ( 'Calendar'[Date] ) + ) + + calculationItem PY = + + CALCULATE ( + SELECTEDMEASURE (), + SAMEPERIODLASTYEAR ( 'Calendar'[Date] ) + ) + + calculationItem YOY% = + + DIVIDE ( + CALCULATE ( + SELECTEDMEASURE (), + 'Time Intelligence'[Show as] = "YOY" + ), + CALCULATE ( + SELECTEDMEASURE (), + 'Time Intelligence'[Show as] = "PY" + ) + ) + + formatStringDefinition = "#,##0.00%" + + calculationItem YOY = + + SELECTEDMEASURE () + - CALCULATE ( + SELECTEDMEASURE (), + 'Time intelligence'[Show as] = "PY" + ) + + calculationItem Current = SELECTEDMEASURE() + + column 'Show as' + dataType: string + lineageTag: b4d28228-3c81-41a7-b590-b4d269d6b4a8 + summarizeBy: none + sourceColumn: Name + sortByColumn: Ordinal + + annotation SummarizationSetBy = Automatic + + column Ordinal + dataType: int64 + formatString: 0 + lineageTag: 018e5f9f-f246-420c-b126-3f455c86d0ec + summarizeBy: sum + sourceColumn: Ordinal + + annotation SummarizationSetBy = Automatic + diff --git a/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/LICENSE.upstream b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/LICENSE.upstream new file mode 100644 index 00000000..9e841e7a --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/LICENSE.upstream @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/database.tmdl b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/database.tmdl new file mode 100644 index 00000000..33b40e20 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/database.tmdl @@ -0,0 +1,3 @@ +database + compatibilityLevel: 1604 + diff --git a/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/expressions.tmdl b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/expressions.tmdl new file mode 100644 index 00000000..37c84540 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/expressions.tmdl @@ -0,0 +1,9 @@ +expression 'DirectLake - BankCustomerChurnLakehouse' = + let + Source = AzureStorage.DataLake("https://onelake.dfs.fabric.microsoft.com/f5d4f720-7eb0-4cc1-b70d-e1d912197072/d9e46ba2-db50-43b6-b3c0-a95889986de7", [HierarchicalNavigation=true]) + in + Source + lineageTag: 6dee88ad-b9ba-479e-9bb2-d4c791436bf3 + + annotation PBI_IncludeFutureArtifacts = False + diff --git a/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/model.tmdl b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/model.tmdl new file mode 100644 index 00000000..02537a50 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/model.tmdl @@ -0,0 +1,16 @@ +model Model + culture: en-US + defaultPowerBIDataSourceVersion: powerBI_V3 + sourceQueryCulture: en-US + dataAccessOptions + legacyRedirects + returnErrorValuesAsNull + +annotation PBI_QueryOrder = ["DirectLake - BankCustomerChurnLakehouse"] + +annotation __PBI_TimeIntelligenceEnabled = 1 + +annotation PBI_ProTooling = ["DirectLakeOnOneLakeInWeb","WebModelingEdit"] + +ref table churn + diff --git a/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/tables/churn.tmdl b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/tables/churn.tmdl new file mode 100644 index 00000000..9af2c3d7 --- /dev/null +++ b/tests/fixtures/external_powerbi/microsoft-fabric-samples-bank-customer-churn/definition/tables/churn.tmdl @@ -0,0 +1,192 @@ +table churn + lineageTag: 2ddb9fb3-8319-4d5b-84bf-2ec439a6a4c0 + sourceLineageTag: [dbo].[churn] + + measure TotalCustomer = DISTINCTCOUNT(churn[CustomerId]) + formatString: 0 + lineageTag: fe5f6bc6-ff5b-4dac-be81-2c00a1621ac8 + + changedProperty = Name + + measure ChurnRate = SUM(churn[Exited])/[TotalCustomer] + lineageTag: 8c6e3b99-dbca-4a49-9a19-8769a16830d5 + + changedProperty = Name + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + measure ActiveRate = SUM(churn[IsActiveMember])/[TotalCustomer] + lineageTag: b6b001cb-0f6d-41e2-97aa-0765cd73ff57 + + changedProperty = Name + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + measure AverageBalance = AVERAGE(churn[Balance]) + formatString: 0 + lineageTag: c29d008f-1672-40ee-8098-3a03ca7c0aeb + + changedProperty = Name + + column RowNumber + dataType: string + lineageTag: c31232df-3954-4a57-8d27-fd0fb144bd88 + sourceLineageTag: RowNumber + summarizeBy: none + sourceColumn: RowNumber + + annotation SummarizationSetBy = Automatic + + column CustomerId + dataType: int64 + formatString: 0 + lineageTag: 7e8a03eb-59c5-4e3e-b232-016ddbec077e + sourceLineageTag: CustomerId + summarizeBy: count + sourceColumn: CustomerId + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column Surname + dataType: string + lineageTag: 1d782da6-ebe6-486b-9261-04e62e29c57c + sourceLineageTag: Surname + summarizeBy: none + sourceColumn: Surname + + annotation SummarizationSetBy = Automatic + + column CreditScore + dataType: int64 + formatString: 0 + lineageTag: ee7b0263-a467-45e0-9c7c-a6a3bd12f046 + sourceLineageTag: CreditScore + summarizeBy: sum + sourceColumn: CreditScore + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column Geography + dataType: string + lineageTag: 7153cd30-364d-44f8-960a-e5a74d69798e + sourceLineageTag: Geography + summarizeBy: none + sourceColumn: Geography + + annotation SummarizationSetBy = Automatic + + column Gender + dataType: string + lineageTag: c258ee4e-8e4c-48cd-881c-3388be3cb54b + sourceLineageTag: Gender + summarizeBy: none + sourceColumn: Gender + + annotation SummarizationSetBy = Automatic + + column Age + dataType: int64 + formatString: 0 + lineageTag: 51426772-d6cb-47b0-ad81-3bd696ee4e2c + sourceLineageTag: Age + summarizeBy: sum + sourceColumn: Age + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column Tenure + dataType: int64 + formatString: 0 + lineageTag: 40c2dacb-f435-4075-8d4c-2038d9dbff27 + sourceLineageTag: Tenure + summarizeBy: sum + sourceColumn: Tenure + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column Balance + dataType: int64 + formatString: 0 + lineageTag: e862ff6b-9942-49e4-9013-8bb56ab6b2f2 + sourceLineageTag: Balance + summarizeBy: sum + sourceColumn: Balance + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column NumOfProducts + dataType: int64 + formatString: 0 + lineageTag: 9d1736b6-1ef4-4106-a9e3-b902a221b71a + sourceLineageTag: NumOfProducts + summarizeBy: sum + sourceColumn: NumOfProducts + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column HasCrCard + dataType: int64 + formatString: 0 + lineageTag: 4e22169f-5db4-462f-bc03-161d37f04b30 + sourceLineageTag: HasCrCard + summarizeBy: sum + sourceColumn: HasCrCard + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column IsActiveMember + dataType: int64 + formatString: 0 + lineageTag: d890424d-0ed7-4fb5-99cf-4bbf05ea43a3 + sourceLineageTag: IsActiveMember + summarizeBy: sum + sourceColumn: IsActiveMember + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column EstimatedSalary + dataType: int64 + formatString: 0 + lineageTag: e1a77879-3dbb-4a76-91cf-7c8ed36dc7e1 + sourceLineageTag: EstimatedSalary + summarizeBy: sum + sourceColumn: EstimatedSalary + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + column Exited + dataType: int64 + formatString: 0 + lineageTag: 96103797-c686-47de-9333-ec22aef5be43 + sourceLineageTag: Exited + summarizeBy: sum + sourceColumn: Exited + + changedProperty = DataType + + annotation SummarizationSetBy = Automatic + + partition churn = entity + mode: directLake + source + entityName: churn + expressionSource: 'DirectLake - BankCustomerChurnLakehouse' + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/LICENSE.upstream b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/LICENSE.upstream new file mode 100644 index 00000000..bdecd921 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/LICENSE.upstream @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Mathias Thierbach + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/cultures/en-US.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/cultures/en-US.tmdl new file mode 100644 index 00000000..ce7f05fd --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/cultures/en-US.tmdl @@ -0,0 +1,4164 @@ +culture en-US + + linguisticMetadata = + { + "Version": "1.2.0", + "Language": "en-US", + "DynamicImprovement": "HighConfidence", + "Entities": { + "customer": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Terms": [ + { + "customer": { + "State": "Generated" + } + }, + { + "customer": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "client": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "consumer": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "user": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "buyer": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "patron": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "purchaser": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "shopper": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + } + ] + }, + "customer.customer_key": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "CustomerKey" + }, + "State": "Generated", + "Hidden": true, + "Terms": [ + { + "customer key": { + "State": "Generated" + } + }, + { + "customer key": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "CustomerKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "client key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "consumer key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "user key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "buyer key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "patron key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "purchaser key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "shopper key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618183710358364 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618159901266505 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618155139448132 + } + } + ] + }, + "customer.customer": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "Customer" + }, + "State": "Generated", + "Terms": [ + { + "customer": { + "State": "Generated" + } + }, + { + "customer": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "customer name": { + "State": "Generated" + } + }, + { + "customer name": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "client": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "consumer": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "user": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "buyer": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "patron": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "purchaser": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "shopper": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + } + ] + }, + "customer.city": { + "Binding": { + "ConceptualEntity": "Customer", + "ConceptualProperty": "City" + }, + "State": "Generated", + "Terms": [ + { + "city": { + "State": "Generated" + } + }, + { + "city": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "location": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "metropolis": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "municipality": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "town": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "metropolitan": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + } + ] + }, + "date": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Terms": [ + { + "date": { + "State": "Generated" + } + }, + { + "date": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "moment": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "period": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + } + ] + }, + "date.date_key": { + "Binding": { + "ConceptualEntity": "Date", + "ConceptualProperty": "DateKey" + }, + "State": "Generated", + "Hidden": true, + "Terms": [ + { + "date key": { + "State": "Generated" + } + }, + { + "date key": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "DateKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "moment key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "period key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.58199998458226532 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618183710358364 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618159901266505 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618155139448132 + } + }, + { + "central": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618150377629764 + } + }, + { + "major": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618145615811391 + } + }, + { + "keynote": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618140853993018 + } + }, + { + "essential": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618136092174645 + } + }, + { + "date solution": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4750000188748042 + } + } + ] + }, + "date.fiscal_quarter": { + "Binding": { + "ConceptualEntity": "Date", + "ConceptualProperty": "Fiscal Quarter" + }, + "State": "Generated", + "Terms": [ + { + "fiscal quarter": { + "State": "Generated" + } + }, + { + "fiscal quarter": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "fiscal qtr": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.83333333333333337 + } + }, + { + "fisc quarter": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.58200000000000007 + } + }, + { + "fiscal qrt": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.58200000000000007 + } + }, + { + "fiscal qrtr": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.58200000000000007 + } + } + ] + }, + "date.month": { + "Binding": { + "ConceptualEntity": "Date", + "ConceptualProperty": "Month" + }, + "State": "Generated", + "Terms": [ + { + "month": { + "State": "Generated" + } + }, + { + "month": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "mth": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + } + ] + }, + "date.month_key": { + "Binding": { + "ConceptualEntity": "Date", + "ConceptualProperty": "MonthKey" + }, + "State": "Generated", + "Hidden": true, + "Terms": [ + { + "month key": { + "State": "Generated" + } + }, + { + "month key": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "MonthKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "mth key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618183710358364 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618159901266505 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618155139448132 + } + }, + { + "central": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618150377629764 + } + }, + { + "major": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618145615811391 + } + }, + { + "keynote": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618140853993018 + } + }, + { + "essential": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618136092174645 + } + }, + { + "month solution": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4750000188748042 + } + }, + { + "month explanation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47499997137480232 + } + } + ] + }, + "sales_territory": { + "Binding": { + "ConceptualEntity": "Sales Territory" + }, + "State": "Generated", + "Terms": [ + { + "sales territory": {} + }, + { + "sale territory": { + "Type": "Noun", + "Weight": 0.78 + } + }, + { + "territory; region": {} + }, + { + "territory": { + "State": "Deleted", + "Weight": 0.97 + } + }, + { + "sale terrain": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.500000019868215 + } + }, + { + "sale region": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.499999869868209 + } + }, + { + "terrain": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49090911041606561 + } + }, + { + "sale land": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "sale ground": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499997077216667 + } + }, + { + "sale area": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499992227216454 + } + }, + { + "sale zone": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499982527216079 + } + }, + { + "sale place": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499977677215883 + } + }, + { + "sale space": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499972827215687 + } + } + ] + }, + "sales_territory.sales_territory_key": { + "Binding": { + "ConceptualEntity": "Sales Territory", + "ConceptualProperty": "SalesTerritoryKey" + }, + "State": "Generated", + "Hidden": true, + "Terms": [ + { + "sales territory key": { + "State": "Generated" + } + }, + { + "sales territory key": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "sale territory key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.78 + } + }, + { + "SalesTerritoryKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "territory key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "territory key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "sale territory solution": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4823077114728781 + } + }, + { + "sale territory explanation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48230766324210694 + } + }, + { + "sale territory basis": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48230747031902238 + } + }, + { + "sale territory recipe": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48230742208825117 + } + }, + { + "sale territory interpretation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48230732562670892 + } + }, + { + "sale territory resolution": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48230727739593776 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618183710358364 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618159901266505 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618155139448132 + } + } + ] + }, + "product": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Terms": [ + { + "product": { + "State": "Generated" + } + }, + { + "product": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "artifact": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "item": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "merchandise": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "produce": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + } + ] + }, + "product.product_key": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "ProductKey" + }, + "State": "Generated", + "Hidden": true, + "Terms": [ + { + "product key": { + "State": "Generated" + } + }, + { + "product key": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "ProductKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "artifact key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.5999999841054281 + } + }, + { + "item key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.58199998458226532 + } + }, + { + "merchandise key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.58199998458226532 + } + }, + { + "produce key": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.58199998458226532 + } + }, + { + "main": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618183710358364 + } + }, + { + "basic": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618159901266505 + } + }, + { + "fundamental": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618155139448132 + } + }, + { + "central": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618150377629764 + } + }, + { + "major": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618145615811391 + } + }, + { + "keynote": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618140853993018 + } + } + ] + }, + "product.color": { + "Binding": { + "ConceptualEntity": "Product", + "ConceptualProperty": "Color" + }, + "State": "Generated", + "Terms": [ + { + "color": { + "State": "Generated" + } + }, + { + "color": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "hue": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49090911041606561 + } + }, + { + "tint": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618183710358364 + } + }, + { + "shade": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618178948539996 + } + }, + { + "dye": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618174186721618 + } + }, + { + "paint": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618169424903251 + } + }, + { + "pigment": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618164663084878 + } + } + ] + }, + "product.product1": { + "Binding": { + "ConceptualEntity": "Product", + "Hierarchy": "Products" + }, + "State": "Generated", + "Terms": [ + { + "product": { + "State": "Generated" + } + }, + { + "product": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "artifact": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "item": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "merchandise": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "produce": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + } + ] + }, + "product.product1.category": { + "Binding": { + "ConceptualEntity": "Product", + "Hierarchy": "Products", + "HierarchyLevel": "Category" + }, + "State": "Generated", + "Terms": [ + { + "category": { + "State": "Generated" + } + }, + { + "category": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "classification": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "class": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "type": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "grouping": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "kind": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + } + ] + }, + "product.product1.subcategory": { + "Binding": { + "ConceptualEntity": "Product", + "Hierarchy": "Products", + "HierarchyLevel": "Subcategory" + }, + "State": "Generated", + "Terms": [ + { + "subcategory": { + "State": "Generated" + } + }, + { + "subcategory": { + "Type": "Noun", + "State": "Generated" + } + } + ] + }, + "product.product1.model": { + "Binding": { + "ConceptualEntity": "Product", + "Hierarchy": "Products", + "HierarchyLevel": "Model" + }, + "State": "Generated", + "Terms": [ + { + "model": { + "State": "Generated" + } + }, + { + "model": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "perfect": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618183710358364 + } + }, + { + "classic": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618164663084878 + } + }, + { + "ideal": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618155139448132 + } + }, + { + "standard": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618145615811391 + } + }, + { + "representative": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.47618140853993018 + } + }, + { + "mockup": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.46636365489526233 + } + }, + { + "representation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.46636360825889683 + } + }, + { + "simulation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.46636337507706932 + } + }, + { + "replica": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.45237274524840443 + } + }, + { + "copy": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.45237265477385541 + } + } + ] + }, + "product.product1.product": { + "Binding": { + "ConceptualEntity": "Product", + "Hierarchy": "Products", + "HierarchyLevel": "Product" + }, + "State": "Generated", + "Terms": [ + { + "product": { + "State": "Generated" + } + }, + { + "product": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "product name": { + "State": "Generated" + } + }, + { + "product name": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "artifact": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "item": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "merchandise": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + }, + { + "produce": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + } + ] + }, + "sales_order": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Terms": [ + { + "sales order": { + "State": "Generated" + } + }, + { + "sales order": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "sale order": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.78 + } + }, + { + "sale instruction": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.500000019868215 + } + }, + { + "sale direction": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49999991986821091 + } + }, + { + "sale edict": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.499999769868205 + } + }, + { + "sale command": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "sale directive": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499997077216667 + } + }, + { + "sale demand": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499987377216269 + } + }, + { + "sale mandate": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499982527216079 + } + }, + { + "sale imperative": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499972827215687 + } + }, + { + "sale stability": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4750000188748042 + } + } + ] + }, + "sales_order.sales_order_line_key": { + "Binding": { + "ConceptualEntity": "Sales Order", + "ConceptualProperty": "SalesOrderLineKey" + }, + "State": "Generated", + "Hidden": true, + "Terms": [ + { + "sales order line key": { + "State": "Generated" + } + }, + { + "sales order line key": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "sale order line key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.78 + } + }, + { + "SalesOrderLineKey": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.99 + } + }, + { + "key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "line key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "line key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "order line key": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "order line key": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "sale order line solution": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48857144798551283 + } + }, + { + "sale order line explanation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48857139912836806 + } + }, + { + "sale order line basis": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4885712036997889 + } + }, + { + "sale order line recipe": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.488571154842644 + } + }, + { + "sale order line interpretation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48857105712835447 + } + }, + { + "sale order line resolution": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48857100827120969 + } + }, + { + "order line solution": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4823077114728781 + } + }, + { + "order line explanation": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48230766324210694 + } + }, + { + "order line basis": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48230747031902238 + } + } + ] + }, + "sales_order.sales_order_line": { + "Binding": { + "ConceptualEntity": "Sales Order", + "ConceptualProperty": "Sales Order Line" + }, + "State": "Generated", + "Terms": [ + { + "sales order line": { + "State": "Generated" + } + }, + { + "sales order line": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "sale order line": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.78 + } + }, + { + "order line": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "order line": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "sale order streak": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49246155803020181 + } + }, + { + "sale order stroke": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49246155803020181 + } + }, + { + "sale order stripe": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4924615087840461 + } + }, + { + "sale order contour": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49246145953789017 + } + }, + { + "sale order mark": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4924614102917344 + } + }, + { + "order streak": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "order stroke": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "order stripe": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499997077216667 + } + }, + { + "order contour": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499992227216454 + } + } + ] + }, + "sales_order.sales_order1": { + "Binding": { + "ConceptualEntity": "Sales Order", + "Hierarchy": "Sales Orders" + }, + "State": "Generated", + "Terms": [ + { + "sales order": { + "State": "Generated" + } + }, + { + "sales order": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "sale order": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.78 + } + }, + { + "sale instruction": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.500000019868215 + } + }, + { + "sale direction": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49999991986821091 + } + }, + { + "sale edict": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.499999769868205 + } + }, + { + "sale command": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "sale directive": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499997077216667 + } + }, + { + "sale demand": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499987377216269 + } + }, + { + "sale mandate": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499982527216079 + } + }, + { + "sale imperative": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499972827215687 + } + }, + { + "sale stability": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4750000188748042 + } + } + ] + }, + "sales_order.sales_order1.sales_order": { + "Binding": { + "ConceptualEntity": "Sales Order", + "Hierarchy": "Sales Orders", + "HierarchyLevel": "Sales Order" + }, + "State": "Generated", + "Terms": [ + { + "sales order": { + "State": "Generated" + } + }, + { + "sales order": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "sale order": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.78 + } + }, + { + "sale instruction": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.500000019868215 + } + }, + { + "sale direction": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49999991986821091 + } + }, + { + "sale edict": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.499999769868205 + } + }, + { + "sale command": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "sale directive": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499997077216667 + } + }, + { + "sale demand": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499987377216269 + } + }, + { + "sale mandate": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499982527216079 + } + }, + { + "sale imperative": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499972827215687 + } + }, + { + "sale stability": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4750000188748042 + } + } + ] + }, + "sales_order.sales_order1.sales_order_line": { + "Binding": { + "ConceptualEntity": "Sales Order", + "Hierarchy": "Sales Orders", + "HierarchyLevel": "Sales Order Line" + }, + "State": "Generated", + "Terms": [ + { + "sales order line": { + "State": "Generated" + } + }, + { + "sales order line": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "sale order line": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.78 + } + }, + { + "order line": { + "State": "Generated", + "Weight": 0.97 + } + }, + { + "order line": { + "Type": "Noun", + "State": "Generated", + "Weight": 0.97 + } + }, + { + "sale order streak": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49246155803020181 + } + }, + { + "sale order stroke": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49246155803020181 + } + }, + { + "sale order stripe": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4924615087840461 + } + }, + { + "sale order contour": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.49246145953789017 + } + }, + { + "sale order mark": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.4924614102917344 + } + }, + { + "order streak": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "order stroke": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48500001927216851 + } + }, + { + "order stripe": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499997077216667 + } + }, + { + "order contour": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.48499992227216454 + } + } + ] + }, + "date.fiscal": { + "Binding": { + "ConceptualEntity": "Date", + "Hierarchy": "Fiscal" + }, + "State": "Generated", + "Terms": [ + { + "fiscal": { + "State": "Generated" + } + }, + { + "fiscal": { + "Type": "Noun", + "State": "Generated" + } + } + ] + }, + "date.fiscal.year": { + "Binding": { + "ConceptualEntity": "Date", + "Hierarchy": "Fiscal", + "HierarchyLevel": "Year" + }, + "State": "Generated", + "Terms": [ + { + "year": { + "State": "Generated" + } + }, + { + "year": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "yr": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + } + ] + }, + "date.fiscal.quarter": { + "Binding": { + "ConceptualEntity": "Date", + "Hierarchy": "Fiscal", + "HierarchyLevel": "Quarter" + }, + "State": "Generated", + "Terms": [ + { + "quarter": { + "State": "Generated" + } + }, + { + "quarter": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "qtr": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.81818181818181823 + } + } + ] + }, + "date.fiscal.month": { + "Binding": { + "ConceptualEntity": "Date", + "Hierarchy": "Fiscal", + "HierarchyLevel": "Month" + }, + "State": "Generated", + "Terms": [ + { + "month": { + "State": "Generated" + } + }, + { + "month": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "mth": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + } + ] + }, + "date.fiscal.date": { + "Binding": { + "ConceptualEntity": "Date", + "Hierarchy": "Fiscal", + "HierarchyLevel": "Date" + }, + "State": "Generated", + "Terms": [ + { + "date": { + "State": "Generated" + } + }, + { + "date": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "date name": { + "State": "Generated" + } + }, + { + "date name": { + "Type": "Noun", + "State": "Generated" + } + }, + { + "moment": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.73636361685666174 + } + }, + { + "period": { + "Type": "Noun", + "State": "Suggested", + "Weight": 0.7142727083509619 + } + } + ] + } + }, + "Relationships": { + "customer_is_named_customer_ID": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.customer_ID": { + "Target": { + "Entity": "customer.customer_ID" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "customer" + }, + "Name": { + "Role": "customer.customer_ID" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.customer_ID" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_is_named_customer": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.customer": { + "Target": { + "Entity": "customer.customer" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "customer" + }, + "Name": { + "Role": "customer.customer" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.customer" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_is_named_date": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date": { + "Target": { + "Entity": "date" + } + }, + "date.date": { + "Target": { + "Entity": "date.date" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "date" + }, + "Name": { + "Role": "date.date" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "date" + }, + "Object": { + "Role": "date.date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_named_product": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.product": { + "Target": { + "Entity": "product.product" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "product" + }, + "Name": { + "Role": "product.product" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.product" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_named_product1_product": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.product1.product": { + "Target": { + "Entity": "product.product1.product" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "product" + }, + "Name": { + "Role": "product.product1.product" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_is_named_fiscal_date": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date": { + "Target": { + "Entity": "date" + } + }, + "date.fiscal.date": { + "Target": { + "Entity": "date.fiscal.date" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "date" + }, + "Name": { + "Role": "date.fiscal.date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_territory_is_named_region": { + "Binding": { + "ConceptualEntity": "Sales Territory" + }, + "State": "Generated", + "Roles": { + "sales_territory": { + "Target": { + "Entity": "sales_territory" + } + }, + "sales_territory.region": { + "Target": { + "Entity": "sales_territory.region" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "sales_territory" + }, + "Name": { + "Role": "sales_territory.region" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "sales_territory" + }, + "Object": { + "Role": "sales_territory.region" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_order_is_named_sales_order_line": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Roles": { + "sales_order": { + "Target": { + "Entity": "sales_order" + } + }, + "sales_order.sales_order_line": { + "Target": { + "Entity": "sales_order.sales_order_line" + } + } + }, + "Phrasings": [ + { + "Name": { + "Subject": { + "Role": "sales_order" + }, + "Name": { + "Role": "sales_order.sales_order_line" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "sales_order" + }, + "Object": { + "Role": "sales_order.sales_order_line" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_expensive": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.standard_cost": { + "Target": { + "Entity": "product.standard_cost" + } + } + }, + "Phrasings": [ + { + "Adjective": { + "Subject": { + "Role": "product" + }, + "Adjectives": [ + { + "expensive": {} + } + ], + "Antonyms": [ + { + "cheap": {} + } + ], + "Measurement": { + "Role": "product.standard_cost" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.standard_cost" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_is_expensive1": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.list_price": { + "Target": { + "Entity": "product.list_price" + } + } + }, + "Phrasings": [ + { + "Adjective": { + "Subject": { + "Role": "product" + }, + "Adjectives": [ + { + "expensive": {} + } + ], + "Antonyms": [ + { + "cheap": {} + } + ], + "Measurement": { + "Role": "product.list_price" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.list_price" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_city": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.city": { + "Target": { + "Entity": "customer.city" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.city" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "customer.city" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_stateprovince": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.stateprovince": { + "Target": { + "Entity": "customer.stateprovince" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.stateprovince" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "customer.stateprovince" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_countryregion": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.countryregion": { + "Target": { + "Entity": "customer.countryregion" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.countryregion" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "customer.countryregion" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_in_postal_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.postal_code": { + "Target": { + "Entity": "customer.postal_code" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "customer.postal_code" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "customer" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "customer.postal_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_territory_in_country": { + "Binding": { + "ConceptualEntity": "Sales Territory" + }, + "State": "Generated", + "Roles": { + "sales_territory": { + "Target": { + "Entity": "sales_territory" + } + }, + "sales_territory.country": { + "Target": { + "Entity": "sales_territory.country" + } + } + }, + "SemanticSlots": { + "Where": { + "Role": "sales_territory.country" + } + }, + "Phrasings": [ + { + "Preposition": { + "Subject": { + "Role": "sales_territory" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "sales_territory.country" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_city": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.city": { + "Target": { + "Entity": "customer.city" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.city" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_stateprovince": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.stateprovince": { + "Target": { + "Entity": "customer.stateprovince" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.stateprovince" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_countryregion": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.countryregion": { + "Target": { + "Entity": "customer.countryregion" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.countryregion" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "customer_has_postal_code": { + "Binding": { + "ConceptualEntity": "Customer" + }, + "State": "Generated", + "Roles": { + "customer": { + "Target": { + "Entity": "customer" + } + }, + "customer.postal_code": { + "Target": { + "Entity": "customer.postal_code" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "customer" + }, + "Object": { + "Role": "customer.postal_code" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_has_date_value": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date": { + "Target": { + "Entity": "date" + } + }, + "date.date_value": { + "Target": { + "Entity": "date.date_value" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date" + }, + "Object": { + "Role": "date.date_value" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_has_fiscal_year": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date": { + "Target": { + "Entity": "date" + } + }, + "date.fiscal_year": { + "Target": { + "Entity": "date.fiscal_year" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date" + }, + "Object": { + "Role": "date.fiscal_year" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_has_fiscal_quarter": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date": { + "Target": { + "Entity": "date" + } + }, + "date.fiscal_quarter": { + "Target": { + "Entity": "date.fiscal_quarter" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date" + }, + "Object": { + "Role": "date.fiscal_quarter" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_has_month": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date": { + "Target": { + "Entity": "date" + } + }, + "date.month": { + "Target": { + "Entity": "date.month" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date" + }, + "Object": { + "Role": "date.month" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_territory_has_country": { + "Binding": { + "ConceptualEntity": "Sales Territory" + }, + "State": "Generated", + "Roles": { + "sales_territory": { + "Target": { + "Entity": "sales_territory" + } + }, + "sales_territory.country": { + "Target": { + "Entity": "sales_territory.country" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_territory" + }, + "Object": { + "Role": "sales_territory.country" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_territory_has_group": { + "Binding": { + "ConceptualEntity": "Sales Territory" + }, + "State": "Generated", + "Roles": { + "sales_territory": { + "Target": { + "Entity": "sales_territory" + } + }, + "sales_territory.group": { + "Target": { + "Entity": "sales_territory.group" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_territory" + }, + "Object": { + "Role": "sales_territory.group" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_color": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.color": { + "Target": { + "Entity": "product.color" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.color" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_model": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.model": { + "Target": { + "Entity": "product.model" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.model" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_subcategory": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.subcategory": { + "Target": { + "Entity": "product.subcategory" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.subcategory" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_category": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.category": { + "Target": { + "Entity": "product.category" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.category" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_sku": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.sku": { + "Target": { + "Entity": "product.sku" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.sku" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_has_product1": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product": { + "Target": { + "Entity": "product" + } + }, + "product.product1": { + "Target": { + "Entity": "product.product1" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product" + }, + "Object": { + "Role": "product.product1" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_order_has_sales_order": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Roles": { + "sales_order": { + "Target": { + "Entity": "sales_order" + } + }, + "sales_order.sales_order": { + "Target": { + "Entity": "sales_order.sales_order" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_order" + }, + "Object": { + "Role": "sales_order.sales_order" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_order_has_sales_order1": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Roles": { + "sales_order": { + "Target": { + "Entity": "sales_order" + } + }, + "sales_order.sales_order1": { + "Target": { + "Entity": "sales_order.sales_order1" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_order" + }, + "Object": { + "Role": "sales_order.sales_order1" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_has_fiscal": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date": { + "Target": { + "Entity": "date" + } + }, + "date.fiscal": { + "Target": { + "Entity": "date.fiscal" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date" + }, + "Object": { + "Role": "date.fiscal" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_order_has_internet_sale": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Roles": { + "sales_order": { + "Target": { + "Entity": "sales_order" + } + }, + "internet_sale": { + "Target": { + "Entity": "internet_sale" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_order" + }, + "Object": { + "Role": "internet_sale" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_product1_has_category": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.product1": { + "Target": { + "Entity": "product.product1" + } + }, + "product.product1.category": { + "Target": { + "Entity": "product.product1.category" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product.product1" + }, + "Object": { + "Role": "product.product1.category" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_product1_has_subcategory": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.product1": { + "Target": { + "Entity": "product.product1" + } + }, + "product.product1.subcategory": { + "Target": { + "Entity": "product.product1.subcategory" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product.product1" + }, + "Object": { + "Role": "product.product1.subcategory" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_product1_has_model": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.product1": { + "Target": { + "Entity": "product.product1" + } + }, + "product.product1.model": { + "Target": { + "Entity": "product.product1.model" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product.product1" + }, + "Object": { + "Role": "product.product1.model" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_product1_has_product": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.product1": { + "Target": { + "Entity": "product.product1" + } + }, + "product.product1.product": { + "Target": { + "Entity": "product.product1.product" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product.product1" + }, + "Object": { + "Role": "product.product1.product" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_product1_category_has_product_product1_subcategory": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.product1.category": { + "Target": { + "Entity": "product.product1.category" + } + }, + "product.product1.subcategory": { + "Target": { + "Entity": "product.product1.subcategory" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product.product1.category" + }, + "Object": { + "Role": "product.product1.subcategory" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product.product1.subcategory" + }, + "Object": { + "Role": "product.product1.category" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Preposition": { + "Subject": { + "Role": "product.product1.subcategory" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "product.product1.category" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_product1_subcategory_has_product_product1_model": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.product1.subcategory": { + "Target": { + "Entity": "product.product1.subcategory" + } + }, + "product.product1.model": { + "Target": { + "Entity": "product.product1.model" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product.product1.subcategory" + }, + "Object": { + "Role": "product.product1.model" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product.product1.model" + }, + "Object": { + "Role": "product.product1.subcategory" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Preposition": { + "Subject": { + "Role": "product.product1.model" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "product.product1.subcategory" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "product_product1_model_has_product_product1_product": { + "Binding": { + "ConceptualEntity": "Product" + }, + "State": "Generated", + "Roles": { + "product.product1.model": { + "Target": { + "Entity": "product.product1.model" + } + }, + "product.product1.product": { + "Target": { + "Entity": "product.product1.product" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "product.product1.model" + }, + "Object": { + "Role": "product.product1.product" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "product.product1.product" + }, + "Object": { + "Role": "product.product1.model" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Preposition": { + "Subject": { + "Role": "product.product1.product" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "product.product1.model" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_order_sales_order1_has_sales_order": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Roles": { + "sales_order.sales_order1": { + "Target": { + "Entity": "sales_order.sales_order1" + } + }, + "sales_order.sales_order1.sales_order": { + "Target": { + "Entity": "sales_order.sales_order1.sales_order" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_order.sales_order1" + }, + "Object": { + "Role": "sales_order.sales_order1.sales_order" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_order_sales_order1_has_sales_order_line": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Roles": { + "sales_order.sales_order1": { + "Target": { + "Entity": "sales_order.sales_order1" + } + }, + "sales_order.sales_order1.sales_order_line": { + "Target": { + "Entity": "sales_order.sales_order1.sales_order_line" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_order.sales_order1" + }, + "Object": { + "Role": "sales_order.sales_order1.sales_order_line" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "sales_order_sales_order1_sales_order_has_sales_order_sales_order1_sales_order_line": { + "Binding": { + "ConceptualEntity": "Sales Order" + }, + "State": "Generated", + "Roles": { + "sales_order.sales_order1.sales_order": { + "Target": { + "Entity": "sales_order.sales_order1.sales_order" + } + }, + "sales_order.sales_order1.sales_order_line": { + "Target": { + "Entity": "sales_order.sales_order1.sales_order_line" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "sales_order.sales_order1.sales_order" + }, + "Object": { + "Role": "sales_order.sales_order1.sales_order_line" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "sales_order.sales_order1.sales_order_line" + }, + "Object": { + "Role": "sales_order.sales_order1.sales_order" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Preposition": { + "Subject": { + "Role": "sales_order.sales_order1.sales_order_line" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "sales_order.sales_order1.sales_order" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_fiscal_has_year": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date.fiscal": { + "Target": { + "Entity": "date.fiscal" + } + }, + "date.fiscal.year": { + "Target": { + "Entity": "date.fiscal.year" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date.fiscal" + }, + "Object": { + "Role": "date.fiscal.year" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_fiscal_has_quarter": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date.fiscal": { + "Target": { + "Entity": "date.fiscal" + } + }, + "date.fiscal.quarter": { + "Target": { + "Entity": "date.fiscal.quarter" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date.fiscal" + }, + "Object": { + "Role": "date.fiscal.quarter" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_fiscal_has_month": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date.fiscal": { + "Target": { + "Entity": "date.fiscal" + } + }, + "date.fiscal.month": { + "Target": { + "Entity": "date.fiscal.month" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date.fiscal" + }, + "Object": { + "Role": "date.fiscal.month" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_fiscal_has_date": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date.fiscal": { + "Target": { + "Entity": "date.fiscal" + } + }, + "date.fiscal.date": { + "Target": { + "Entity": "date.fiscal.date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date.fiscal" + }, + "Object": { + "Role": "date.fiscal.date" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_fiscal_year_has_date_fiscal_quarter": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date.fiscal.year": { + "Target": { + "Entity": "date.fiscal.year" + } + }, + "date.fiscal.quarter": { + "Target": { + "Entity": "date.fiscal.quarter" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date.fiscal.year" + }, + "Object": { + "Role": "date.fiscal.quarter" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "date.fiscal.quarter" + }, + "Object": { + "Role": "date.fiscal.year" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Preposition": { + "Subject": { + "Role": "date.fiscal.quarter" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "date.fiscal.year" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_fiscal_quarter_has_date_fiscal_month": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date.fiscal.quarter": { + "Target": { + "Entity": "date.fiscal.quarter" + } + }, + "date.fiscal.month": { + "Target": { + "Entity": "date.fiscal.month" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date.fiscal.quarter" + }, + "Object": { + "Role": "date.fiscal.month" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "date.fiscal.month" + }, + "Object": { + "Role": "date.fiscal.quarter" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Preposition": { + "Subject": { + "Role": "date.fiscal.month" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "date.fiscal.quarter" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + }, + "date_fiscal_month_has_date_fiscal_date": { + "Binding": { + "ConceptualEntity": "Date" + }, + "State": "Generated", + "Roles": { + "date.fiscal.month": { + "Target": { + "Entity": "date.fiscal.month" + } + }, + "date.fiscal.date": { + "Target": { + "Entity": "date.fiscal.date" + } + } + }, + "Phrasings": [ + { + "Attribute": { + "Subject": { + "Role": "date.fiscal.month" + }, + "Object": { + "Role": "date.fiscal.date" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Attribute": { + "Subject": { + "Role": "date.fiscal.date" + }, + "Object": { + "Role": "date.fiscal.month" + } + }, + "State": "Generated", + "Weight": 0.99 + }, + { + "Preposition": { + "Subject": { + "Role": "date.fiscal.date" + }, + "Prepositions": [ + { + "in": {} + } + ], + "Object": { + "Role": "date.fiscal.month" + } + }, + "State": "Generated", + "Weight": 0.99 + } + ] + } + } + } + contentType: json + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/database.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/database.tmdl new file mode 100644 index 00000000..fa9c12c9 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/database.tmdl @@ -0,0 +1,3 @@ +database 'Adventure Works DW 2020' + compatibilityLevel: 1550 + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/expressions.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/expressions.tmdl new file mode 100644 index 00000000..92a681bb --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/expressions.tmdl @@ -0,0 +1,6 @@ +expression HttpSource = "https://raw.githubusercontent.com/pbi-tools/adventureworksdw2020-pbix/main/data/" meta [IsParameterQuery=true, Type="Text", IsParameterQueryRequired=true] + + annotation PBI_NavigationStepName = Navigation + + annotation PBI_ResultType = Text + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/model.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/model.tmdl new file mode 100644 index 00000000..57d7f5ef --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/model.tmdl @@ -0,0 +1,22 @@ +model Model + culture: en-US + defaultPowerBIDataSourceVersion: powerBI_V3 + sourceQueryCulture: en-US + dataAccessOptions + legacyRedirects + returnErrorValuesAsNull + +annotation __PBI_TimeIntelligenceEnabled = 0 + +annotation PBIDesktopVersion = 2.103.661.0 (22.03) + +annotation PBI_QueryOrder = ["HttpSource","Customer","Date","Product","Reseller","Sales","Sales Order","Sales Territory"] + +ref table Customer +ref table Date +ref table Product +ref table Reseller +ref table Sales +ref table 'Sales Order' +ref table 'Sales Territory' + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/relationships.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/relationships.tmdl new file mode 100644 index 00000000..b97786c0 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/relationships.tmdl @@ -0,0 +1,36 @@ +relationship c4007daa-09a5-455d-ac3b-d8338a0e4468 + fromColumn: Sales.SalesTerritoryKey + toColumn: 'Sales Territory'.SalesTerritoryKey + +relationship fe440ad4-cbfb-4a8c-9b24-4d02f59a009f + fromColumn: Sales.ProductKey + toColumn: Product.ProductKey + +relationship ddc90e12-74d0-451e-87b6-3bc8d773bf07 + crossFilteringBehavior: bothDirections + fromCardinality: one + fromColumn: Sales.SalesOrderLineKey + toColumn: 'Sales Order'.SalesOrderLineKey + +relationship 3921d624-3ba4-40ca-b78d-61fe4ebc7659 + fromColumn: Sales.CustomerKey + toColumn: Customer.CustomerKey + +relationship ad03fb2c-8d99-47eb-bdab-0e52920c9d3f + fromColumn: Sales.OrderDateKey + toColumn: Date.DateKey + +relationship a390c257-6a75-4c82-aab5-270f564d26b0 + isActive: false + fromColumn: Sales.DueDateKey + toColumn: Date.DateKey + +relationship fcf11ed1-afec-495f-8897-4461f7a9d501 + isActive: false + fromColumn: Sales.ShipDateKey + toColumn: Date.DateKey + +relationship f72f8f53-10b5-4d0a-82ea-19e584697a64 + fromColumn: Sales.ResellerKey + toColumn: Reseller.ResellerKey + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Customer.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Customer.tmdl new file mode 100644 index 00000000..aa047b1f --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Customer.tmdl @@ -0,0 +1,77 @@ +table Customer + + column City + dataType: string + dataCategory: City + summarizeBy: none + sourceColumn: City + + annotation SummarizationSetBy = Automatic + + column Country-Region + dataType: string + summarizeBy: none + sourceColumn: Country-Region + + annotation SummarizationSetBy = Automatic + + column 'Customer ID' + dataType: string + summarizeBy: none + sourceColumn: Customer ID + + annotation SummarizationSetBy = Automatic + + column Customer + dataType: string + isDefaultLabel + summarizeBy: none + sourceColumn: Customer + + annotation SummarizationSetBy = Automatic + + column CustomerKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: CustomerKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column 'Postal Code' + dataType: string + summarizeBy: none + sourceColumn: Postal Code + + annotation SummarizationSetBy = Automatic + + column State-Province + dataType: string + summarizeBy: none + sourceColumn: State-Province + + annotation SummarizationSetBy = Automatic + + hierarchy Geography + + level City + column: City + + level Customer + column: Customer + + partition Customer = m + mode: import + source = + let + Source = Csv.Document(Web.Contents(HttpSource & "Customer.csv"),[Delimiter=",", Columns=7, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"CustomerKey", Int64.Type}, {"Customer ID", type text}, {"Customer", type text}, {"City", type text}, {"State-Province", type text}, {"Country-Region", type text}, {"Postal Code", type text}}) + in + #"Changed Type" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Date.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Date.tmdl new file mode 100644 index 00000000..a7f4f896 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Date.tmdl @@ -0,0 +1,88 @@ +/// Filters the Sales table using sales order date +table Date + + column Date + dataType: dateTime + formatString: Long Date + summarizeBy: none + sourceColumn: Date + + annotation SummarizationSetBy = Automatic + + annotation UnderlyingDateTimeDataType = Date + + column DateKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: DateKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column 'Fiscal Quarter' + dataType: string + summarizeBy: none + sourceColumn: Fiscal Quarter + + annotation SummarizationSetBy = Automatic + + column 'Fiscal Year' + dataType: string + summarizeBy: none + sourceColumn: Fiscal Year + + annotation SummarizationSetBy = Automatic + + column 'Full Date' + dataType: string + isDefaultLabel + summarizeBy: none + sourceColumn: Full Date + + annotation SummarizationSetBy = Automatic + + column Month + dataType: string + summarizeBy: none + sourceColumn: Month + + annotation SummarizationSetBy = Automatic + + column MonthKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: MonthKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + hierarchy Fiscal + + level Quarter + column: 'Fiscal Quarter' + + level Month + column: Month + + level Date + column: 'Full Date' + + partition Date = m + mode: import + source = + let + Source = Csv.Document(Web.Contents(HttpSource & "Date.csv"),[Delimiter=",", Columns=7, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"DateKey", Int64.Type}, {"Date", type datetime}, {"Fiscal Year", type text}, {"Fiscal Quarter", type text}, {"Month", type text}, {"MonthKey", Int64.Type}, {"Full Date", type text}}), + #"Extracted Date" = Table.TransformColumns(#"Changed Type",{{"Date", DateTime.Date, type date}}) + in + #"Extracted Date" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Product.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Product.tmdl new file mode 100644 index 00000000..9cfcb020 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Product.tmdl @@ -0,0 +1,87 @@ +table Product + + column Category + dataType: string + summarizeBy: none + sourceColumn: Category + + annotation SummarizationSetBy = Automatic + + column Color + dataType: string + summarizeBy: none + sourceColumn: Color + + annotation SummarizationSetBy = Automatic + + column 'List Price' + dataType: decimal + formatString: "£"#,0.###############;-"£"#,0.###############;"£"#,0.############### + summarizeBy: none + sourceColumn: List Price + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"currencyCulture":"en-GB"} + + column Model + dataType: string + summarizeBy: none + sourceColumn: Model + + annotation SummarizationSetBy = Automatic + + column Product + dataType: string + summarizeBy: none + sourceColumn: Product + + annotation SummarizationSetBy = Automatic + + column ProductKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: ProductKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column SKU + dataType: string + summarizeBy: none + sourceColumn: SKU + + annotation SummarizationSetBy = Automatic + + column 'Standard Cost' + dataType: decimal + formatString: "£"#,0.###############;-"£"#,0.###############;"£"#,0.############### + summarizeBy: none + sourceColumn: Standard Cost + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"currencyCulture":"en-GB"} + + column Subcategory + dataType: string + summarizeBy: none + sourceColumn: Subcategory + + annotation SummarizationSetBy = Automatic + + partition Product = m + mode: import + source = + let + Source = Csv.Document(Web.Contents(HttpSource & "Product.csv"),[Delimiter=",", Columns=9, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"ProductKey", Int64.Type}, {"Product", type text}, {"Standard Cost", Currency.Type}, {"Color", type text}, {"List Price", Currency.Type}, {"Model", type text}, {"Subcategory", type text}}) + in + #"Changed Type" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Reseller.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Reseller.tmdl new file mode 100644 index 00000000..7485e5b6 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Reseller.tmdl @@ -0,0 +1,80 @@ +table Reseller + + column 'Business Type' + dataType: string + summarizeBy: none + sourceColumn: Business Type + + annotation SummarizationSetBy = Automatic + + column City + dataType: string + dataCategory: City + summarizeBy: none + sourceColumn: City + + annotation SummarizationSetBy = Automatic + + column Country-Region + dataType: string + summarizeBy: none + sourceColumn: Country-Region + + annotation SummarizationSetBy = Automatic + + column 'Postal Code' + dataType: string + summarizeBy: none + sourceColumn: Postal Code + + annotation SummarizationSetBy = Automatic + + column 'Reseller ID' + dataType: string + summarizeBy: none + sourceColumn: Reseller ID + + annotation SummarizationSetBy = Automatic + + column Reseller + dataType: string + summarizeBy: none + sourceColumn: Reseller + + annotation SummarizationSetBy = Automatic + + column ResellerKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: ResellerKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column State-Province + dataType: string + summarizeBy: none + sourceColumn: State-Province + + annotation SummarizationSetBy = Automatic + + hierarchy Geography + + level City + column: City + + partition Reseller = m + mode: import + source = + let + Source = Csv.Document(Web.Contents(HttpSource & "Reseller.csv"),[Delimiter=",", Columns=8, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"ResellerKey", Int64.Type}, {"Business Type", type text}, {"Reseller", type text}, {"City", type text}, {"State-Province", type text}, {"Country-Region", type text}, {"Postal Code", type text}}) + in + #"Changed Type" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Order.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Order.tmdl new file mode 100644 index 00000000..214c5c08 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Order.tmdl @@ -0,0 +1,52 @@ +table 'Sales Order' + + column Channel + dataType: string + summarizeBy: none + sourceColumn: Channel + + annotation SummarizationSetBy = Automatic + + column 'Sales Order Line' + dataType: string + isDefaultLabel + summarizeBy: none + sourceColumn: Sales Order Line + + annotation SummarizationSetBy = Automatic + + column 'Sales Order' + dataType: string + summarizeBy: none + sourceColumn: Sales Order + + annotation SummarizationSetBy = Automatic + + column SalesOrderLineKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: SalesOrderLineKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + hierarchy 'Sales Orders' + + level 'Sales Order Line' + column: 'Sales Order Line' + + partition 'Sales Order' = m + mode: import + source = + let + Source = Csv.Document(Web.Contents(HttpSource & "Sales Order.csv"),[Delimiter=",", Columns=4, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"SalesOrderLineKey", Int64.Type}, {"Sales Order", type text}, {"Sales Order Line", type text}, {"Channel", type text}}) + in + #"Changed Type" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Territory.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Territory.tmdl new file mode 100644 index 00000000..b474253c --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales Territory.tmdl @@ -0,0 +1,46 @@ +table 'Sales Territory' + + column Country + dataType: string + summarizeBy: none + sourceColumn: Country + + annotation SummarizationSetBy = Automatic + + column Group + dataType: string + summarizeBy: none + sourceColumn: Group + + annotation SummarizationSetBy = Automatic + + column Region + dataType: string + summarizeBy: none + sourceColumn: Region + + annotation SummarizationSetBy = Automatic + + column SalesTerritoryKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: SalesTerritoryKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + partition 'Sales Territory' = m + mode: import + source = + let + Source = Csv.Document(Web.Contents(HttpSource & "Sales Territory.csv"),[Delimiter=",", Columns=4, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"SalesTerritoryKey", Int64.Type}, {"Region", type text}, {"Country", type text}, {"Group", type text}}) + in + #"Changed Type" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales.tmdl b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales.tmdl new file mode 100644 index 00000000..ffe8bd04 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbi-tools-adventureworks-dw2020/tables/Sales.tmdl @@ -0,0 +1,169 @@ +table Sales + + column CustomerKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: CustomerKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column DueDateKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: count + sourceColumn: DueDateKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column 'Extended Amount' + dataType: decimal + formatString: "£"#,0.###############;-"£"#,0.###############;"£"#,0.############### + summarizeBy: sum + sourceColumn: Extended Amount + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"currencyCulture":"en-GB"} + + column 'Order Quantity' + dataType: int64 + formatString: 0 + summarizeBy: sum + sourceColumn: Order Quantity + + annotation SummarizationSetBy = Automatic + + column OrderDateKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: OrderDateKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column 'Product Standard Cost' + dataType: decimal + formatString: "£"#,0.###############;-"£"#,0.###############;"£"#,0.############### + summarizeBy: sum + sourceColumn: Product Standard Cost + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"currencyCulture":"en-GB"} + + column ProductKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: ProductKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column ResellerKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: ResellerKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column 'Sales Amount' + dataType: decimal + formatString: "£"#,0.###############;-"£"#,0.###############;"£"#,0.############### + summarizeBy: sum + sourceColumn: Sales Amount + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"currencyCulture":"en-GB"} + + column SalesOrderLineKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: SalesOrderLineKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column SalesTerritoryKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: SalesTerritoryKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column ShipDateKey + dataType: int64 + isHidden + formatString: 0 + summarizeBy: count + sourceColumn: ShipDateKey + + annotation SummarizationSetBy = Automatic + + annotation PBI_ChangedProperties = ["IsHidden"] + + column 'Total Product Cost' + dataType: decimal + formatString: "£"#,0.###############;-"£"#,0.###############;"£"#,0.############### + summarizeBy: sum + sourceColumn: Total Product Cost + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"currencyCulture":"en-GB"} + + column 'Unit Price Discount Pct' + dataType: double + summarizeBy: sum + sourceColumn: Unit Price Discount Pct + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"isGeneralNumber":true} + + column 'Unit Price' + dataType: decimal + formatString: "£"#,0.###############;-"£"#,0.###############;"£"#,0.############### + summarizeBy: sum + sourceColumn: Unit Price + + annotation SummarizationSetBy = Automatic + + annotation PBI_FormatHint = {"currencyCulture":"en-GB"} + + partition Sales = m + mode: import + source = + let + Source = Csv.Document(Web.Contents(HttpSource & "Sales.csv"),[Delimiter=",", Columns=15, Encoding=65001, QuoteStyle=QuoteStyle.None]), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars=true]), + #"Changed Type" = Table.TransformColumnTypes(#"Promoted Headers",{{"SalesOrderLineKey", Int64.Type}, {"ResellerKey", Int64.Type}, {"CustomerKey", Int64.Type}, {"ProductKey", Int64.Type}, {"OrderDateKey", Int64.Type}, {"DueDateKey", Int64.Type}, {"ShipDateKey", Int64.Type}, {"SalesTerritoryKey", Int64.Type}, {"Order Quantity", Int64.Type}, {"Unit Price", Currency.Type}, {"Extended Amount", Currency.Type}, {"Product Standard Cost", Currency.Type}, {"Total Product Cost", Currency.Type}, {"Sales Amount", Currency.Type}, {"Unit Price Discount Pct", type number}}) + in + #"Changed Type" + + annotation PBI_ResultType = Table + diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/LICENSE.upstream b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/LICENSE.upstream new file mode 100644 index 00000000..b296f1f7 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/LICENSE.upstream @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jihwan Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/expressions.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/expressions.tmdl new file mode 100644 index 00000000..bb0b618e --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/expressions.tmdl @@ -0,0 +1,8 @@ +expression sales_src = m + let + Source = Sql.Database("myserver.database.windows.net", "mydb"), + dbo_Sales = Source{[Schema="dbo",Item="fact_sales"]}[Data], + Renamed = Table.RenameColumns(dbo_Sales, {{"order_date", "OrderDate"}, {"sale_amount", "Amount"}, {"product_id", "ProductID"}, {"customer_id", "CustomerID"}, {"qty", "Quantity"}}) + in + Renamed + lineageTag: abc123 diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/relationships.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/relationships.tmdl new file mode 100644 index 00000000..3096d812 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/relationships.tmdl @@ -0,0 +1,11 @@ +relationship Sales_Products + fromColumn: Sales.ProductID + toColumn: Products.ProductID + +relationship Sales_Customers + fromColumn: Sales.CustomerID + toColumn: Customers.CustomerID + +relationship Sales_DateTable + fromColumn: Sales.OrderDate + toColumn: DateTable.Date diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Customers.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Customers.tmdl new file mode 100644 index 00000000..b9cd3cc8 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Customers.tmdl @@ -0,0 +1,13 @@ +table Customers + column CustomerID + dataType: int64 + sourceColumn: CustomerID + column CustomerName + dataType: string + sourceColumn: CustomerName + column Region + dataType: string + sourceColumn: Region + column Segment + dataType: string + sourceColumn: Segment diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/DateTable.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/DateTable.tmdl new file mode 100644 index 00000000..3e840dd4 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/DateTable.tmdl @@ -0,0 +1,16 @@ +table DateTable + column Date + dataType: dateTime + sourceColumn: Date + column Year + dataType: int64 + sourceColumn: Year + column Month + dataType: string + sourceColumn: Month + column Quarter + dataType: string + sourceColumn: Quarter + column MonthNumber + dataType: int64 + sourceColumn: MonthNumber diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/FieldParameter.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/FieldParameter.tmdl new file mode 100644 index 00000000..86043466 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/FieldParameter.tmdl @@ -0,0 +1,21 @@ +table 'Sales Metrics' + column Value + dataType: int64 + column Metric + dataType: string + expression: NAMEOF([Total Sales]) & NAMEOF([Avg Sales]) & NAMEOF([Total Quantity]) + measure 'Selected Metric' = + SWITCH( + SELECTEDVALUE('Sales Metrics'[Value]), + 0, [Total Sales], + 1, [Avg Sales], + 2, [Total Quantity] + ) + partition 'Sales Metrics' = calculated + mode: import + source = + { + ("sales_kpi_01", NAMEOF('Sales'[Total Sales]), 0), + ("sales_kpi_02", NAMEOF('Sales'[Avg Sales]), 1), + ("sales_kpi_03", NAMEOF('Sales'[Total Quantity]), 2) + } diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Products.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Products.tmdl new file mode 100644 index 00000000..7e44e500 --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Products.tmdl @@ -0,0 +1,15 @@ +table Products + column ProductID + dataType: int64 + sourceColumn: ProductID + column ProductName + dataType: string + sourceColumn: ProductName + column Category + dataType: string + sourceColumn: Category + column UnitPrice + dataType: decimal + sourceColumn: UnitPrice + measure 'Product Count' = DISTINCTCOUNT(Products[ProductID]) + formatString: #,##0 diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Sales.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Sales.tmdl new file mode 100644 index 00000000..c2daf47d --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/Sales.tmdl @@ -0,0 +1,33 @@ +table Sales + column OrderDate + dataType: dateTime + sourceColumn: OrderDate + column Amount + dataType: decimal + sourceColumn: Amount + column ProductID + dataType: int64 + sourceColumn: ProductID + column CustomerID + dataType: int64 + sourceColumn: CustomerID + column Quantity + dataType: int64 + sourceColumn: Quantity + measure 'Total Sales' = SUM(Sales[Amount]) + formatString: $#,##0.00 + measure 'Avg Sales' = AVERAGE(Sales[Amount]) + formatString: $#,##0.00 + measure 'YoY Growth' = + VAR CurrentYear = CALCULATE([Total Sales], DATESINPERIOD(DateTable[Date], MAX(DateTable[Date]), -1, YEAR)) + VAR PreviousYear = CALCULATE([Total Sales], DATESINPERIOD(DateTable[Date], MAX(DateTable[Date]), -2, YEAR)) + RETURN + DIVIDE(CurrentYear - PreviousYear, PreviousYear) + formatString: 0.00% + measure 'Total Quantity' = SUM(Sales[Quantity]) + formatString: #,##0 + measure 'Unused Metric' = COUNTROWS(Sales) * 0 + formatString: #,##0 + partition Sales = m + mode: import + source = sales_src diff --git a/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/TimeCalc.tmdl b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/TimeCalc.tmdl new file mode 100644 index 00000000..dd9a7cab --- /dev/null +++ b/tests/fixtures/external_powerbi/pbip-lineage-explorer-sample/definition/tables/TimeCalc.tmdl @@ -0,0 +1,7 @@ +table TimeCalcGroup + calculationGroup + column Name + dataType: string + calculationItem YTD = CALCULATE(SELECTEDMEASURE(), DATESYTD(DateTable[Date])) + calculationItem QTD = CALCULATE(SELECTEDMEASURE(), DATESQTD(DateTable[Date])) + calculationItem MTD = CALCULATE(SELECTEDMEASURE(), DATESMTD(DateTable[Date])) diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/LICENSE.upstream b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/LICENSE.upstream new file mode 100644 index 00000000..d53c9615 --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/LICENSE.upstream @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Rui Romano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/database.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/database.tmdl new file mode 100644 index 00000000..ffba38ae --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/database.tmdl @@ -0,0 +1,3 @@ +database + compatibilityLevel: 1601 + diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/expressions.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/expressions.tmdl new file mode 100644 index 00000000..ee01dbfc --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/expressions.tmdl @@ -0,0 +1 @@ +expression HttpSource = "https://raw.githubusercontent.com/pbi-tools/sales-sample/data/" meta [IsParameterQuery=true, Type="Text", IsParameterQueryRequired=true] \ No newline at end of file diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/model.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/model.tmdl new file mode 100644 index 00000000..517e4932 --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/model.tmdl @@ -0,0 +1,23 @@ +model Model + culture: en-US + defaultPowerBIDataSourceVersion: powerBI_V3 + discourageImplicitMeasures + sourceQueryCulture: en-GB + dataAccessOptions + legacyRedirects + returnErrorValuesAsNull + +annotation PBI_ProTooling = ["TMDL-Extension"] + +ref table Calendar +ref table Sales +ref table Product + +ref role 'Store - Canada' +ref role 'Store - United States' + +ref perspective Sales + +ref cultureInfo en-US +ref cultureInfo pt-PT + diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/relationships.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/relationships.tmdl new file mode 100644 index 00000000..5d24131f --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/relationships.tmdl @@ -0,0 +1,17 @@ +relationship bb5c5591-a0ff-4ce4-a62e-6c5f56006368 + fromColumn: Sales.ProductKey + toColumn: Product.ProductKey + +relationship 55a6f513-c6f2-4d1c-b8aa-46edeaeb23f2 + fromColumn: Sales.StoreKey + toColumn: Store.StoreKey + +relationship 079ad58b-2f43-efa0-7fb6-5775474da9b9 + fromColumn: Sales.'Order Date' + toColumn: Calendar.Date + +relationship 92b8a424-f739-c57d-a8de-be6b9ea34685 + isActive: false + fromColumn: Sales.'Delivery Date' + toColumn: Calendar.Date + diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - Canada.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - Canada.tmdl new file mode 100644 index 00000000..80a8a19d --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - Canada.tmdl @@ -0,0 +1,7 @@ +role 'Store - Canada' + modelPermission: read + + tablePermission Store = [Country] == "Canada" + + annotation PBI_Id = 5587cca136da46789bfeb4c2de02c98e + diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - United States.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - United States.tmdl new file mode 100644 index 00000000..ced753b1 --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/roles/Store - United States.tmdl @@ -0,0 +1,7 @@ +role 'Store - United States' + modelPermission: read + + tablePermission Store = [Country] == "United States" + + annotation PBI_Id = be15f15fb63049e7a3d04c99d7554ba2 + diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Calendar.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Calendar.tmdl new file mode 100644 index 00000000..785c1be4 --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Calendar.tmdl @@ -0,0 +1,148 @@ +table Calendar + + column Day + dataType: int64 + formatString: 0 + summarizeBy: none + sourceColumn: Day + + column Month + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: Month + + column Quarter + dataType: string + summarizeBy: none + sourceColumn: Quarter + + column Year + dataType: int64 + formatString: 0 + summarizeBy: none + sourceColumn: Year + + column Date + dataType: dateTime + isHidden + isKey + formatString: Long Date + summarizeBy: none + sourceColumn: Date + + column 'Month Name' + dataType: string + isHidden + summarizeBy: none + sourceColumn: Month Name + sortByColumn: Month + + column Year-Month + dataType: dateTime + formatString: mmm yyyy + summarizeBy: none + sourceColumn: Year-Month + + column 'Week Number' + dataType: int64 + formatString: 0 + summarizeBy: none + sourceColumn: Week Number + + column 'Day of Week' + dataType: int64 + formatString: 0 + summarizeBy: none + sourceColumn: Day of Week + + column 'Day Name' + dataType: string + summarizeBy: none + sourceColumn: Day Name + sortByColumn: 'Day of Week' + + column 'Is Weekend' + dataType: boolean + isHidden + formatString: """TRUE"";""TRUE"";""FALSE""" + summarizeBy: none + sourceColumn: Is Weekend + + column 'Fiscal Year' + dataType: int64 + isHidden + formatString: 0 + summarizeBy: none + sourceColumn: Fiscal Year + + column 'Fiscal Quarter' + dataType: string + summarizeBy: none + sourceColumn: Fiscal Quarter + + hierarchy Year-Month-Day + + level Year + column: Year + + level Month + column: Month + + level Day + column: Day + + partition Calendar-c9bc757b-0dad-4b99-8287-18a451a3c5c3 = m + mode: import + source = ``` + let + P_Today = DateTime.LocalNow(), + StartDate = #date(Date.Year(P_Today) - 3, 1, 1), + EndDate = #date(Date.Year(P_Today), 12, 31), + // Generate a list of dates + DateList = List.Dates(StartDate, Duration.Days(EndDate - StartDate) + 1, #duration(1, 0, 0, 0)), + // Convert list to a table + Calendar = Table.FromList(DateList, Splitter.SplitByNothing(), {"Date"}), + // Add columns for different date attributes + AddYear = Table.AddColumn(Calendar, "Year", each Date.Year([Date])), + AddMonth = Table.AddColumn(AddYear, "Month", each Date.Month([Date])), + AddDay = Table.AddColumn(AddMonth, "Day", each Date.Day([Date])), + AddMonthName = Table.AddColumn(AddDay, "Month Name", each Date.ToText([Date], "MMM")), + AddYearMonth = Table.AddColumn( + AddMonthName, "Year-Month", each Text.From(Date.Year([Date])) & " " & Date.ToText([Date], "MMM") + ), + //AddYearMonthKey = Table.AddColumn(AddYearMonth, "Year-Month Key", each Date.Year([Date]) * 100 + Date.Month([Date])), + AddQuarter = Table.AddColumn(AddYearMonth, "Quarter", each "Q" & Text.From(Date.QuarterOfYear([Date]))), + AddWeek = Table.AddColumn(AddQuarter, "Week Number", each Date.WeekOfYear([Date])), + AddDayOfWeek = Table.AddColumn(AddWeek, "Day of Week", each Date.DayOfWeek([Date]) + 1), + AddDayName = Table.AddColumn(AddDayOfWeek, "Day Name", each Date.ToText([Date], "dddd")), + AddIsWeekend = Table.AddColumn(AddDayName, "Is Weekend", each if Date.DayOfWeek([Date]) >= 5 then true else false), + // Add fiscal year and period adjustments + AddFiscalYear = Table.AddColumn( + AddIsWeekend, "Fiscal Year", each if Date.Month([Date]) >= 7 then Date.Year([Date]) + 1 else Date.Year([Date]) + ), + AddFiscalQuarter = Table.AddColumn( + AddFiscalYear, "Fiscal Quarter", each "FQ" & Text.From(Number.IntegerDivide((Date.Month([Date]) + 5), 3)) + ), + #"Changed Type" = Table.TransformColumnTypes( + AddFiscalQuarter, + { + {"Date", type date}, + {"Year", Int64.Type}, + {"Month", Int64.Type}, + {"Day", Int64.Type}, + {"Month Name", type text}, + {"Year-Month", type date}, + {"Quarter", type text}, + {"Week Number", Int64.Type}, + {"Day of Week", Int64.Type}, + {"Day Name", type text}, + {"Is Weekend", type logical}, + {"Fiscal Year", Int64.Type}, + {"Fiscal Quarter", type text} + } + ) + in + #"Changed Type" + ``` diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Product.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Product.tmdl new file mode 100644 index 00000000..56075646 --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Product.tmdl @@ -0,0 +1,120 @@ +table Product + + measure '# Products' = COUNTROWS('Product') + formatString: #,##0 + + column Product + dataType: string + isDefaultLabel + summarizeBy: none + sourceColumn: Product + + column ProductKey + dataType: int64 + isHidden + isKey + formatString: 0 + isAvailableInMdx: false + summarizeBy: none + sourceColumn: ProductKey + + column 'Product Code' + dataType: string + summarizeBy: none + sourceColumn: Product Code + + column Manufacturer + dataType: string + summarizeBy: none + sourceColumn: Manufacturer + + column Brand + dataType: string + summarizeBy: none + sourceColumn: Brand + + column Color + dataType: string + summarizeBy: none + sourceColumn: Color + + column 'Weight Unit Measure' + dataType: string + summarizeBy: none + sourceColumn: Weight Unit Measure + + column Weight + dataType: decimal + summarizeBy: none + sourceColumn: Weight + + column 'Unit Cost' + dataType: decimal + summarizeBy: none + sourceColumn: Unit Cost + + column 'Unit Price' + dataType: decimal + summarizeBy: none + sourceColumn: Unit Price + + column 'Subcategory Code' + dataType: string + summarizeBy: none + sourceColumn: Subcategory Code + + column Subcategory + dataType: string + summarizeBy: none + sourceColumn: Subcategory + + column 'Category Code' + dataType: string + summarizeBy: none + sourceColumn: Category Code + + column Category + dataType: string + summarizeBy: none + sourceColumn: Category + + partition Product-171f48b3-e0ea-4ea3-b9a0-c8c673eb0648 = m + mode: import + source = + let + Source = Csv.Document( + Web.Contents(HttpSource, [RelativePath = "RAW-Product.csv"]), + [ + Delimiter = ",", + Columns = 14, + Encoding = 65001, + QuoteStyle = QuoteStyle.None + ] + ), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars = true]), + #"Changed Column Types" = Table.TransformColumnTypes( + #"Promoted Headers", + { + {"ProductKey", Int64.Type}, + {"Product Code", type text}, + {"Product Name", type text}, + {"Manufacturer", type text}, + {"Brand", type text}, + {"Color", type text}, + {"Weight Unit Measure", type text}, + {"Weight", Currency.Type}, + {"Unit Cost", Currency.Type}, + {"Unit Price", Currency.Type}, + {"Subcategory Code", type text}, + {"Subcategory", type text}, + {"Category Code", type text}, + {"Category", type text} + } + ), + #"Renamed Columns" = Table.RenameColumns(#"Changed Column Types", {{"Product Name", "Product"}}) + in + #"Renamed Columns" + + annotation PBI_ResultType = Table + + annotation PBI_NavigationStepName = Navigation diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Sales.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Sales.tmdl new file mode 100644 index 00000000..d113aa6e --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Sales.tmdl @@ -0,0 +1,255 @@ +table Sales + + measure '# Customers (w/ Sales)' = DISTINCTCOUNT('Sales'[CustomerKey]) + formatString: #,##0 + + measure '# Products (w/ Sales)' = DISTINCTCOUNT('Sales'[ProductKey]) + formatString: #,##0 + + measure 'Sales Qty' = sum('Sales'[Quantity]) + formatString: #,##0 + + measure 'Sales Amount' = SUMX('Sales', 'Sales'[Quantity] * 'Sales'[Net Price]) + formatString: $ #,##0 + + + /// Sales Amount Last Year considering a full month + measure 'Sales Amount (LY)' = IF ([Sales Amount] > 0, CALCULATE([Sales Amount], SAMEPERIODLASTYEAR('Calendar'[Date]))) + formatString: "€"#,0.###############;("€"#,0.###############);"€"#,0.############### + + + measure 'Sales Amount Avg per Day' = AVERAGEX(VALUES('Calendar'[Date]), [Sales Amount]) + formatString: $ #,##0 + + measure Margin = + SUMX ( + Sales, + Sales[Quantity] + * ( Sales[Net Price] - Sales[Unit Cost] ) + ) + formatString: $ #,##0 + + measure 'Margin (LY)' = CALCULATE([Margin], SAMEPERIODLASTYEAR('Calendar'[Date])) + formatString: $ #,##0 + + measure '# Sales' = COUNTROWS('Sales') + formatString: #,##02 + + measure 'Sales Amount (12M average)' = + + VAR v_selDate = + MAX ( 'Calendar'[Date] ) + VAR v_period = + DATESINPERIOD ( 'Calendar'[Date], v_selDate, -12, MONTH ) + VAR v_result = + CALCULATE ( AVERAGEX ( VALUES ( 'Calendar'[Date] ), [Sales Amount] ), v_period ) + VAR v_firstDate = + MINX ( v_period, 'Calendar'[Date] ) + VAR v_lastDateSales = + MAX ( Sales[Order Date] ) + RETURN + IF ( v_firstDate <= v_lastDateSales, v_result ) + formatString: $ #,##0 + + + measure 'Sales Amount (6M average)' = + VAR v_selDate = + MAX ( 'Calendar'[Date] ) + VAR v_period = + DATESINPERIOD ( 'Calendar'[Date], v_selDate, -6, MONTH ) + VAR v_result = + CALCULATE ( AVERAGEX ( VALUES ( 'Calendar'[Date] ), [Sales Amount] ), v_period ) + VAR v_firstDate = + MINX ( v_period, 'Calendar'[Date] ) + VAR v_lastDateSales = + MAX ( Sales[Order Date] ) + RETURN + IF ( v_firstDate <= v_lastDateSales, v_result ) + formatString: $ #,##0 + + measure 'Margin %' = DIVIDE ( [Margin], [Sales Amount] ) + formatString: #,##0.00 % + + measure Cost = SUMX ( Sales, Sales[Quantity] * Sales[Unit Cost] ) + formatString: $ #,##0 + + column 'Order Number' + dataType: int64 + formatString: 0 + summarizeBy: none + sourceColumn: Order Number + + column 'Line Number' + dataType: int64 + formatString: 0 + summarizeBy: none + sourceColumn: Line Number + + column 'Order Date' + dataType: dateTime + formatString: Long Date + summarizeBy: none + sourceColumn: Order Date + + column 'Delivery Date' + dataType: dateTime + formatString: Long Date + summarizeBy: none + sourceColumn: Delivery Date + + column CustomerKey + dataType: int64 + isHidden + formatString: 0 + isAvailableInMdx: false + summarizeBy: none + sourceColumn: CustomerKey + + column StoreKey + dataType: int64 + isHidden + formatString: 0 + isAvailableInMdx: false + summarizeBy: none + sourceColumn: StoreKey + + column ProductKey + dataType: int64 + isHidden + formatString: 0 + isAvailableInMdx: false + summarizeBy: none + sourceColumn: ProductKey + + column 'Unit Cost' + dataType: decimal + isHidden + isAvailableInMdx: false + summarizeBy: sum + sourceColumn: Unit Cost + + + column 'Currency Code' + dataType: string + summarizeBy: none + sourceColumn: Currency Code + + column 'Exchange Rate' + dataType: decimal + summarizeBy: none + sourceColumn: Exchange Rate + + + column Environment + dataType: string + summarizeBy: none + sourceColumn: Environment + + column Time + dataType: dateTime + formatString: Long Time + summarizeBy: none + sourceColumn: Time + + column Quantity + dataType: int64 + formatString: 0 + summarizeBy: sum + sourceColumn: Quantity + + column 'Net Price' + dataType: decimal + formatString: "€"#,0.###############;("€"#,0.###############);"€"#,0.############### + summarizeBy: sum + sourceColumn: Net Price + + + partition Sales-ddb4c40b-46fd-49ea-9a19-16e7e640a21a = m + mode: import + source = ``` + let + // RAW data + Source = Csv.Document( + Web.Contents(HttpSource, [RelativePath = "RAW-Sales.csv"]), + [ + Delimiter = ",", + Columns = 13, + Encoding = 65001, + QuoteStyle = QuoteStyle.None + ] + ), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars = true]), + #"Changed Column Types" = Table.TransformColumnTypes( + #"Promoted Headers", + { + {"Order Number", Int64.Type}, + {"Line Number", Int64.Type}, + {"Order Date", type date}, + {"Delivery Date", type date}, + {"CustomerKey", Int64.Type}, + {"StoreKey", Int64.Type}, + {"ProductKey", Int64.Type}, + {"Quantity", Int64.Type}, + {"Unit Price", Currency.Type}, + {"Net Price", Currency.Type}, + {"Unit Cost", Currency.Type}, + {"Currency Code", type text}, + {"Exchange Rate", Currency.Type} + } + ), + #"Added 'Time'" = Table.AddColumn( + #"Changed Column Types", + "Time", + each #time(Number.RoundDown(Number.RandomBetween(0, 23), 0), Number.RoundDown(Number.RandomBetween(0, 59), 0), 0), + type time + ), + // Randomize data + minDate = List.Min(#"Added 'Time'"[Order Date]), + numYearsOnDummyData = 3, + yearsDiff = (Date.Year(DateTime.LocalNow()) - numYearsOnDummyData) - Date.Year(minDate), + #"AdjustDates" = Table.TransformColumns( + #"Added 'Time'", + {{"Order Date", each Date.AddYears(_, yearsDiff)}, {"Delivery Date", each Date.AddYears(_, yearsDiff)}} + ), + #"Added [NewQuantity]" = Table.AddColumn( + #"AdjustDates", + "NewQuantity", + each + Number.RoundDown( + Number.RandomBetween( + List.Max({1, [Quantity] - ([Quantity] * Randomizer)}), List.Min( + {[Quantity], [Quantity] + ([Quantity] * Randomizer)} + ) + ) + ), + Int64.Type + ), + #"Added [NewNetPrice]" = Table.AddColumn( + #"Added [NewQuantity]", + "NewNetPrice", + each + Number.Round( + Number.RandomBetween( + List.Max({1, [Net Price] - ([Net Price] * Randomizer)}), + List.Min({[Net Price], [Net Price] + ([Net Price] * Randomizer)}) + ), + 3 + ), + Currency.Type + ), + #"Removed Columns" = Table.RemoveColumns(#"Added [NewNetPrice]", {"Quantity", "Net Price"}), + #"Renamed Columns" = Table.RenameColumns( + #"Removed Columns", {{"NewQuantity", "Quantity"}, {"NewNetPrice", "Net Price"}} + ), + #"Removed Columns2" = Table.RemoveColumns(#"Renamed Columns", {"Unit Price"}), + #"Changed Type1" = Table.TransformColumnTypes( + #"Removed Columns2", {{"Delivery Date", type datetime}, {"Order Date", type datetime}} + ), + #"Filtered Rows" = Table.SelectRows(#"Changed Type1", each [Order Date] >= RangeStart and [Order Date] <= RangeEnd), + #"Changed Type2" = Table.TransformColumnTypes( + #"Filtered Rows", {{"Delivery Date", type date}, {"Order Date", type date}} + ), + #"Added Custom" = Table.AddColumn(#"Changed Type2", "Environment", each Environment, type text) + in + #"Added Custom" + ``` diff --git a/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Store.tmdl b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Store.tmdl new file mode 100644 index 00000000..e9d26b48 --- /dev/null +++ b/tests/fixtures/external_powerbi/ruiromano-pbip-demo-agentic-model01/definition/tables/Store.tmdl @@ -0,0 +1,90 @@ +table Store + + measure '# Stores' = COUNTROWS('Store') + formatString: #,##0 + + column StoreKey + dataType: int64 + isHidden + isKey + formatString: 0 + isAvailableInMdx: false + summarizeBy: none + sourceColumn: StoreKey + + column 'Store Code' + dataType: string + summarizeBy: none + sourceColumn: Store Code + + column Country + dataType: string + dataCategory: Uncategorized + summarizeBy: none + sourceColumn: Country + + column State + dataType: string + summarizeBy: none + sourceColumn: State + + column Store + dataType: string + isDefaultLabel + summarizeBy: none + sourceColumn: Store + + column 'Square Meters' + dataType: int64 + formatString: 0 + summarizeBy: none + sourceColumn: Square Meters + + column 'Open Date' + dataType: dateTime + formatString: Long Date + summarizeBy: none + sourceColumn: Open Date + + column 'Close Date' + dataType: dateTime + formatString: Long Date + summarizeBy: count + sourceColumn: Close Date + + column Status + dataType: string + summarizeBy: none + sourceColumn: Status + + partition Store-c0e5ba98-f95a-4712-91ec-71c7dc35e177 = m + mode: import + source = + let + Source = Csv.Document( + Web.Contents(HttpSource, [RelativePath = "RAW-Store.csv"]), + [ + Delimiter = ",", + Columns = 9, + Encoding = 65001, + QuoteStyle = QuoteStyle.None + ] + ), + #"Promoted Headers" = Table.PromoteHeaders(Source, [PromoteAllScalars = true]), + #"Changed Column Types" = Table.TransformColumnTypes( + #"Promoted Headers", + { + {"StoreKey", Int64.Type}, + {"Store Code", type text}, + {"Country", type text}, + {"State", type text}, + {"Name", type text}, + {"Square Meters", Int64.Type}, + {"Open Date", type date}, + {"Close Date", type date}, + {"Status", type text} + } + ), + #"Renamed Columns" = Table.RenameColumns(#"Changed Column Types", {{"Name", "Store"}}) + in + #"Renamed Columns" diff --git a/tests/fixtures/tmdl/definition/database.tmdl b/tests/fixtures/tmdl/definition/database.tmdl new file mode 100644 index 00000000..125e873e --- /dev/null +++ b/tests/fixtures/tmdl/definition/database.tmdl @@ -0,0 +1,2 @@ +database 'Sales Model' + model 'Sales Model' diff --git a/tests/fixtures/tmdl/definition/model.tmdl b/tests/fixtures/tmdl/definition/model.tmdl new file mode 100644 index 00000000..8a9e044b --- /dev/null +++ b/tests/fixtures/tmdl/definition/model.tmdl @@ -0,0 +1,5 @@ +model 'Sales Model' + ref table 'Sales' + ref table 'Products' + ref relationship 'Sales-Products' + expression Server = "localhost" meta [IsParameterQuery=true, Type="Text"] diff --git a/tests/fixtures/tmdl/definition/relationships.tmdl b/tests/fixtures/tmdl/definition/relationships.tmdl new file mode 100644 index 00000000..5c5b7e3c --- /dev/null +++ b/tests/fixtures/tmdl/definition/relationships.tmdl @@ -0,0 +1,6 @@ +relationship 'Sales-Products' + fromColumn: 'Sales'[Product Key] + toColumn: 'Products'[Product Key] + fromCardinality: many + toCardinality: one + isActive diff --git a/tests/fixtures/tmdl/definition/tables/Products.tmdl b/tests/fixtures/tmdl/definition/tables/Products.tmdl new file mode 100644 index 00000000..9f64abe7 --- /dev/null +++ b/tests/fixtures/tmdl/definition/tables/Products.tmdl @@ -0,0 +1,9 @@ +/// Product dimension +table Products + column 'Product Key' + dataType: int64 + isKey + sourceColumn: ProductKey + column 'Product Name' + dataType: string + sourceColumn: ProductName diff --git a/tests/fixtures/tmdl/definition/tables/Sales.tmdl b/tests/fixtures/tmdl/definition/tables/Sales.tmdl new file mode 100644 index 00000000..910ec858 --- /dev/null +++ b/tests/fixtures/tmdl/definition/tables/Sales.tmdl @@ -0,0 +1,25 @@ +# comment that should be ignored +/// Sales fact table +table 'Sales' + column 'Sale ID' + dataType: int64 + isKey + sourceColumn: SaleID + column 'Product Key' + dataType: int64 + sourceColumn: ProductKey + column 'Order Date' + dataType: date + sourceColumn: OrderDate + column Amount + dataType: decimal + sourceColumn: Amount + formatString: "$#,##0.00" + measure 'Total Sales' = SUM('Sales'[Amount]) + formatString: "$#,##0.00" + measure 'Sales LY' = + VAR ly = CALCULATE([Total Sales], SAMEPERIODLASTYEAR('Sales'[Order Date])) + RETURN ly + measure 'Backtick Measure' = ``` + SUM('Sales'[Amount]) + ``` diff --git a/tests/fixtures/tmdl_realistic/definition/database.tmdl b/tests/fixtures/tmdl_realistic/definition/database.tmdl new file mode 100644 index 00000000..2560a6e0 --- /dev/null +++ b/tests/fixtures/tmdl_realistic/definition/database.tmdl @@ -0,0 +1,6 @@ +/// Retail analytics model +database 'Retail Analytics' + compatibilityLevel: 1601 + annotation DatabaseTag + value: "retail" + model 'Retail Analytics' diff --git a/tests/fixtures/tmdl_realistic/definition/model.tmdl b/tests/fixtures/tmdl_realistic/definition/model.tmdl new file mode 100644 index 00000000..74d5bcfa --- /dev/null +++ b/tests/fixtures/tmdl_realistic/definition/model.tmdl @@ -0,0 +1,19 @@ +model 'Retail Analytics' + defaultPowerBIDataSourceVersion: powerBI_V3 + ref table Sales + ref table Products + ref table Calendar + ref table 'Sales By Category' + ref relationship 'Sales-Products' + ref relationship 'Sales-Calendar' + perspective Executive + annotation Scope + value: "leadership" + culture en-US + annotation Locale + value: "en-US" + +role 'Sales Managers' + modelPermission: read + annotation RoleTag + value: "managed" diff --git a/tests/fixtures/tmdl_realistic/definition/relationships.tmdl b/tests/fixtures/tmdl_realistic/definition/relationships.tmdl new file mode 100644 index 00000000..73057729 --- /dev/null +++ b/tests/fixtures/tmdl_realistic/definition/relationships.tmdl @@ -0,0 +1,15 @@ +relationship 'Sales-Products' + fromColumn: Sales[ProductKey] + toColumn: Products[ProductKey] + fromCardinality: many + toCardinality: one + isActive + annotation RelationshipLineage + value: "sales_to_products" + +relationship 'Sales-Calendar' + fromColumn: Sales[OrderDate] + toColumn: Calendar[Date] + fromCardinality: many + toCardinality: one + crossFilteringBehavior: bothDirections diff --git a/tests/fixtures/tmdl_realistic/definition/tables/Calendar.tmdl b/tests/fixtures/tmdl_realistic/definition/tables/Calendar.tmdl new file mode 100644 index 00000000..9a246d39 --- /dev/null +++ b/tests/fixtures/tmdl_realistic/definition/tables/Calendar.tmdl @@ -0,0 +1,12 @@ +/// Calendar dimension +table Calendar + column Date + dataType: date + isKey + sourceColumn: Date + column Year + dataType: int64 + sourceColumn: Year + column MonthName + dataType: string + sourceColumn: MonthName diff --git a/tests/fixtures/tmdl_realistic/definition/tables/Products.tmdl b/tests/fixtures/tmdl_realistic/definition/tables/Products.tmdl new file mode 100644 index 00000000..fc942d13 --- /dev/null +++ b/tests/fixtures/tmdl_realistic/definition/tables/Products.tmdl @@ -0,0 +1,12 @@ +/// Product dimension +table Products + column ProductKey + dataType: int64 + isKey + sourceColumn: ProductKey + column ProductName + dataType: string + sourceColumn: ProductName + column Category + dataType: string + sourceColumn: Category diff --git a/tests/fixtures/tmdl_realistic/definition/tables/Sales By Category.tmdl b/tests/fixtures/tmdl_realistic/definition/tables/Sales By Category.tmdl new file mode 100644 index 00000000..ffa6962c --- /dev/null +++ b/tests/fixtures/tmdl_realistic/definition/tables/Sales By Category.tmdl @@ -0,0 +1,9 @@ +calculatedTable 'Sales By Category' = SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount])) + annotation CalculationTag + value: "category_rollup" + column Category + dataType: string + sourceColumn: Category + column Revenue + dataType: decimal + sourceColumn: Revenue diff --git a/tests/fixtures/tmdl_realistic/definition/tables/Sales.tmdl b/tests/fixtures/tmdl_realistic/definition/tables/Sales.tmdl new file mode 100644 index 00000000..727b398d --- /dev/null +++ b/tests/fixtures/tmdl_realistic/definition/tables/Sales.tmdl @@ -0,0 +1,37 @@ +/// Sales fact table +table Sales + annotation TableTag + value: "fact" + column SalesKey + dataType: int64 + isKey + sourceColumn: SalesKey + column ProductKey + dataType: int64 + sourceColumn: ProductKey + column OrderDate + dataType: date + sourceColumn: OrderDate + column Quantity + dataType: int64 + sourceColumn: Quantity + column Amount + dataType: decimal + sourceColumn: Amount + formatString: "$#,##0.00" + calculatedColumn 'Amount x2' = Sales[Amount] * 2 + dataType: decimal + measure 'Total Sales' = SUM(Sales[Amount]) + formatString: "$#,##0.00" + measure 'Order Count' = COUNTROWS(Sales) + measure 'Sales LY' = + VAR ly = CALCULATE([Total Sales], SAMEPERIODLASTYEAR(Calendar[Date])) + RETURN ly + partition Sales = m + mode: import + source = + let + Source = Sql.Database("localhost", "retail"), + Sales = Source{[Schema="dbo",Item="Sales"]}[Data] + in + Sales diff --git a/tests/fixtures/tmdl_warning/definition/model.tmdl b/tests/fixtures/tmdl_warning/definition/model.tmdl new file mode 100644 index 00000000..25df5a33 --- /dev/null +++ b/tests/fixtures/tmdl_warning/definition/model.tmdl @@ -0,0 +1,4 @@ +model 'Warning Fixture' + ref table Sales + ref table 'Bad Table' + ref relationship 'Bad-Relationship' diff --git a/tests/fixtures/tmdl_warning/definition/relationships.tmdl b/tests/fixtures/tmdl_warning/definition/relationships.tmdl new file mode 100644 index 00000000..14fb3155 --- /dev/null +++ b/tests/fixtures/tmdl_warning/definition/relationships.tmdl @@ -0,0 +1,5 @@ +relationship 'Bad-Relationship' + fromColumn: SalesCategory + toColumn: Missing[Category] + fromCardinality: many + toCardinality: one diff --git a/tests/fixtures/tmdl_warning/definition/tables/Bad Table.tmdl b/tests/fixtures/tmdl_warning/definition/tables/Bad Table.tmdl new file mode 100644 index 00000000..3852d50a --- /dev/null +++ b/tests/fixtures/tmdl_warning/definition/tables/Bad Table.tmdl @@ -0,0 +1,4 @@ +calculatedTable 'Bad Table' = UNKNOWNTABLEFN(Sales) + column Category + dataType: string + sourceColumn: Category diff --git a/tests/fixtures/tmdl_warning/definition/tables/Sales.tmdl b/tests/fixtures/tmdl_warning/definition/tables/Sales.tmdl new file mode 100644 index 00000000..e5338f72 --- /dev/null +++ b/tests/fixtures/tmdl_warning/definition/tables/Sales.tmdl @@ -0,0 +1,15 @@ +table Sales + column SalesKey + dataType: int64 + isKey + sourceColumn: SalesKey + column Category + dataType: string + sourceColumn: Category + column Amount + dataType: decimal + sourceColumn: Amount + calculatedColumn 'Bad Column' = UNKNOWNFUNC(Sales[Amount]) + dataType: decimal + measure Revenue = SUM(Sales[Amount]) + measure 'Bad Measure' = UNKNOWNFUNC(Sales[Amount]) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 4e61c209..86b021f4 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1,5 +1,6 @@ """Tests for CLI command wiring.""" +import textwrap from pathlib import Path import duckdb @@ -9,6 +10,7 @@ import sidemantic.cli as cli_module from sidemantic.cli import app from sidemantic.config import SidemanticConfig +from sidemantic.loaders import load_from_directory as load_models_from_directory from tests.optional_dep_stubs import ensure_fake_mcp, ensure_fake_riffq runner = CliRunner() @@ -33,6 +35,34 @@ def _write_min_model(directory: Path) -> None: ) +def _write_min_tmdl_model(path: Path) -> None: + path.write_text( + textwrap.dedent( + """ + table Sales + column SaleID + dataType: int64 + isKey + sourceColumn: SaleID + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + """ + ) + ) + + +def _require_sidemantic_dax_native(): + sidemantic_dax = pytest.importorskip("sidemantic_dax") + try: + sidemantic_dax.parse_expression("1") + except RuntimeError as exc: + if "native module is not available" in str(exc): + pytest.skip("sidemantic_dax native module not available") + raise + + def _write_orders_db(path: Path) -> None: conn = duckdb.connect(str(path)) conn.execute("CREATE TABLE orders (id INTEGER, status VARCHAR)") @@ -340,6 +370,234 @@ def fake_run(*args, **kwargs): assert called.get("run") is True +def test_dax_query_dry_run_outputs_translated_sql(monkeypatch, tmp_path): + monkeypatch.setattr( + "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", + lambda self, dax, evaluate=1: {"sql": "SELECT 1 AS one", "warnings": [], "import_warnings": []}, + ) + + _write_min_model(tmp_path) + result = runner.invoke( + app, + [ + "dax-query", + 'EVALUATE ROW("one", 1)', + "--models", + str(tmp_path), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "SELECT 1 AS one" in result.stdout + + +def test_dax_query_dry_run_uses_real_dax_parser_and_translator(tmp_path): + _require_sidemantic_dax_native() + + _write_min_model(tmp_path) + result = runner.invoke( + app, + [ + "dax-query", + """EVALUATE SUMMARIZECOLUMNS('orders'[status], "Orders", [order_count])""", + "--models", + str(tmp_path), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert result.stdout == "SELECT orders.status, COUNT(*) AS Orders FROM orders GROUP BY orders.status\n" + + +def test_dax_query_dry_run_loads_standalone_tmdl_file(tmp_path): + _require_sidemantic_dax_native() + + tmdl_file = tmp_path / "Sales.tmdl" + _write_min_tmdl_model(tmdl_file) + + result = runner.invoke( + app, + [ + "dax-query", + 'EVALUATE ROW("Revenue", [Revenue])', + "--models", + str(tmdl_file), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert result.stdout == "SELECT SUM(Sales.Amount) AS Revenue FROM Sales\n" + + +def test_dax_query_executes_real_dax_against_duckdb_file(tmp_path): + _require_sidemantic_dax_native() + duckdb = pytest.importorskip("duckdb") + + _write_min_model(tmp_path) + db_path = tmp_path / "orders.duckdb" + conn = duckdb.connect(str(db_path)) + try: + conn.execute("CREATE TABLE orders (id INTEGER, status VARCHAR)") + conn.execute("INSERT INTO orders VALUES (1, 'open'), (2, 'open'), (3, 'closed')") + finally: + conn.close() + + result = runner.invoke( + app, + [ + "dax-query", + """EVALUATE SUMMARIZECOLUMNS('orders'[status], "Orders", [order_count]) """ + "ORDER BY 'orders'[status] ASC", + "--models", + str(tmp_path), + "--db", + str(db_path), + ], + ) + + assert result.exit_code == 0 + assert result.stdout == "status,Orders\nclosed,1\nopen,2\n" + + +def test_dax_query_executes_through_database_adapter(monkeypatch, tmp_path): + monkeypatch.setattr( + "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", + lambda self, dax, evaluate=1: {"sql": "SELECT 1 AS one", "warnings": [], "import_warnings": []}, + ) + executed = [] + + class FakeResult: + description = [("one",)] + + def fetchall(self): + return [(1,)] + + def _execute(self, sql): + executed.append(sql) + return FakeResult() + + monkeypatch.setattr("sidemantic.db.duckdb.DuckDBAdapter.execute", _execute) + + _write_min_model(tmp_path) + result = runner.invoke( + app, + [ + "dax-query", + 'EVALUATE ROW("one", 1)', + "--models", + str(tmp_path), + ], + ) + + assert result.exit_code == 0 + assert executed == ["SELECT 1 AS one"] + assert "one" in result.stdout + assert "1" in result.stdout + + +def test_dax_query_evaluate_index_out_of_range(monkeypatch, tmp_path): + def _raise_out_of_range(self, dax, evaluate=1): + raise ValueError("evaluate index 2 is out of range; query has 1 EVALUATE statement(s)") + + monkeypatch.setattr("sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", _raise_out_of_range) + + _write_min_model(tmp_path) + result = runner.invoke( + app, + [ + "dax-query", + 'EVALUATE ROW("one", 1)', + "--models", + str(tmp_path), + "--evaluate", + "2", + ], + ) + + assert result.exit_code == 1 + assert "out of range" in result.stderr + + +def test_dax_query_emits_import_warnings(monkeypatch, tmp_path): + monkeypatch.setattr( + "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", + lambda self, dax, evaluate=1: {"sql": "SELECT 1 AS one", "warnings": [], "import_warnings": []}, + ) + + def _load_with_warning(layer, directory): + load_models_from_directory(layer, directory) + layer.graph.import_warnings = [ + { + "code": "dax_translation_fallback", + "context": "measure", + "name": "Revenue", + "message": "Unsupported table expression", + "file": "definition/tables/Sales.tmdl", + "line": 12, + "column": 8, + } + ] + + monkeypatch.setattr("sidemantic.cli.load_from_directory", _load_with_warning) + + _write_min_model(tmp_path) + result = runner.invoke( + app, + [ + "dax-query", + 'EVALUATE ROW("one", 1)', + "--models", + str(tmp_path), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "SELECT 1 AS one" in result.stdout + assert "import warning(s)" in result.stderr + assert "dax_translation_fallback" in result.stderr + + +def test_dax_query_emits_translation_warnings(monkeypatch, tmp_path): + monkeypatch.setattr( + "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", + lambda self, dax, evaluate=1: { + "sql": "SELECT 1 AS one", + "warnings": [ + { + "code": "dax_unrelated_cross_join", + "context": "query", + "base_table": "sales", + "table": "products", + "message": "DAX query cross joins unrelated table 'products' with 'sales'", + } + ], + "import_warnings": [], + }, + ) + + _write_min_model(tmp_path) + result = runner.invoke( + app, + [ + "dax-query", + 'EVALUATE ROW("one", 1)', + "--models", + str(tmp_path), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "SELECT 1 AS one" in result.stdout + assert "DAX query warning(s)" in result.stderr + assert "dax_unrelated_cross_join" in result.stderr + assert "base_table=sales" in result.stderr + + def test_docker_entrypoint_does_not_use_eval(): entrypoint_path = Path(__file__).resolve().parent.parent / "docker-entrypoint.sh" content = entrypoint_path.read_text() diff --git a/tests/test_core_imports.py b/tests/test_core_imports.py new file mode 100644 index 00000000..f0c76f34 --- /dev/null +++ b/tests/test_core_imports.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +import subprocess +import sys + + +def test_core_imports_do_not_load_optional_dax_runtime(): + code = """ +import json +import sys +from sidemantic import Dimension, Metric, Model + +print(json.dumps({ + "classes": [Model.__name__, Dimension.__name__, Metric.__name__], + "sidemantic_dax_loaded": "sidemantic_dax" in sys.modules, +})) +""" + + result = subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, text=True) + + assert json.loads(result.stdout) == { + "classes": ["Model", "Dimension", "Metric"], + "sidemantic_dax_loaded": False, + } + + +def test_non_dax_yaml_load_does_not_load_optional_dax_runtime(tmp_path): + model_path = tmp_path / "models.yml" + model_path.write_text( + """ +models: + - name: orders + table: orders + primary_key: id + dimensions: + - name: status + type: categorical + metrics: + - name: order_count + agg: count +""" + ) + code = f""" +import json +import sys +from sidemantic import SemanticLayer + +layer = SemanticLayer.from_yaml({str(model_path)!r}) +print(json.dumps({{ + "models": list(layer.graph.models), + "sidemantic_dax_loaded": "sidemantic_dax" in sys.modules, +}})) +""" + + result = subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, text=True) + + assert json.loads(result.stdout) == { + "models": ["orders"], + "sidemantic_dax_loaded": False, + } diff --git a/tests/test_loaders.py b/tests/test_loaders.py index fa13f9f8..4124613e 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -31,3 +31,92 @@ def blocked_antlr4_import(name, *args, **kwargs): load_from_directory(layer, tmp_path) assert "orders" in layer.graph.models + + +def test_load_from_directory_surfaces_adapter_parse_failures(tmp_path, monkeypatch): + from sidemantic.adapters.sidemantic import SidemanticAdapter + + (tmp_path / "broken.yml").write_text("models:\n - name: broken\n") + + def _raise_parse_failure(self, path): + raise ValueError("simulated native yaml failure") + + monkeypatch.setattr(SidemanticAdapter, "parse", _raise_parse_failure) + + layer = SemanticLayer() + load_from_directory(layer, tmp_path) + + warnings = layer.describe_models()["import_warnings"] + assert warnings == [ + { + "code": "adapter_parse_error", + "context": "loader", + "source_format": "Sidemantic", + "source_file": "broken.yml", + "message": "simulated native yaml failure", + } + ] + + +def test_load_from_directory_surfaces_tmdl_project_parse_failures(tmp_path, monkeypatch): + from sidemantic.adapters.tmdl import TMDLAdapter + + tmdl_file = tmp_path / "definition" / "tables" / "Sales.tmdl" + tmdl_file.parent.mkdir(parents=True) + tmdl_file.write_text("table Sales\n") + + def _raise_parse_failure(self, path): + raise ValueError("simulated tmdl failure") + + monkeypatch.setattr(TMDLAdapter, "parse", _raise_parse_failure) + + layer = SemanticLayer() + load_from_directory(layer, tmp_path) + + warnings = layer.describe_models()["import_warnings"] + assert { + "code": "tmdl_parse_error", + "context": "loader", + "source_format": "TMDL", + "source_file": "definition", + "message": "simulated tmdl failure", + } in warnings + + +def test_load_from_directory_does_not_partially_parse_tmdl_project_after_project_failure(tmp_path, monkeypatch): + from sidemantic.adapters.tmdl import TMDLAdapter + from sidemantic.core.model import Model + from sidemantic.core.semantic_graph import SemanticGraph + + definition_dir = tmp_path / "definition" + tmdl_file = definition_dir / "tables" / "Sales.tmdl" + tmdl_file.parent.mkdir(parents=True) + tmdl_file.write_text("table Sales\n") + + calls: list[Path] = [] + + def _parse_project_only(self, path): + source = Path(path) + calls.append(source) + if source.is_dir(): + raise ValueError("simulated project-level failure") + graph = SemanticGraph() + graph.add_model(Model(name="PartialSales", table="sales", primary_key="id")) + return graph + + monkeypatch.setattr(TMDLAdapter, "parse", _parse_project_only) + + layer = SemanticLayer() + load_from_directory(layer, tmp_path) + + assert calls == [definition_dir] + assert layer.graph.models == {} + assert layer.describe_models()["import_warnings"] == [ + { + "code": "tmdl_parse_error", + "context": "loader", + "source_format": "TMDL", + "source_file": "definition", + "message": "simulated project-level failure", + } + ] diff --git a/tests/test_relationship_override_sql.py b/tests/test_relationship_override_sql.py new file mode 100644 index 00000000..947a6055 --- /dev/null +++ b/tests/test_relationship_override_sql.py @@ -0,0 +1,114 @@ +"""Regression tests for metric-local relationship overrides in SQL generation.""" + +from sidemantic.core.dimension import Dimension +from sidemantic.core.metric import Metric +from sidemantic.core.model import Model +from sidemantic.core.relationship import Relationship, RelationshipOverride +from sidemantic.core.semantic_graph import SemanticGraph +from sidemantic.sql.generator import SQLGenerator + + +def test_metric_relationship_override_replaces_default_join_path(): + graph = SemanticGraph() + graph.add_model( + Model( + name="Sales", + table="sales", + primary_key="SalesKey", + relationships=[ + Relationship( + name="Calendar", + type="many_to_one", + foreign_key="OrderDateKey", + primary_key="DateKey", + ), + Relationship( + name="Calendar", + type="many_to_one", + foreign_key="ShipDateKey", + primary_key="DateKey", + active=False, + ), + ], + metrics=[ + Metric( + name="Ship Sales", + agg="sum", + sql="Amount", + required_models=["Calendar"], + relationship_overrides=[ + RelationshipOverride( + from_model="Sales", + from_column="ShipDateKey", + to_model="Calendar", + to_column="DateKey", + ) + ], + ) + ], + ) + ) + graph.add_model( + Model( + name="Calendar", + table="calendar", + primary_key="DateKey", + dimensions=[Dimension(name="Date", type="time", sql="Date")], + ) + ) + + sql = SQLGenerator(graph).generate(metrics=["Sales.Ship Sales"], dimensions=["Calendar.Date"]) + + assert "ShipDateKey" in sql + assert "DateKey = Sales_cte.ShipDateKey" in sql + assert "OrderDateKey = Calendar_cte.DateKey" not in sql + assert "Calendar_cte.DateKey = Sales_cte.OrderDateKey" not in sql + + +def test_crossfilter_join_type_override_is_used_for_join(): + graph = SemanticGraph() + graph.add_model( + Model( + name="Sales", + table="sales", + primary_key="SalesKey", + relationships=[ + Relationship( + name="Products", + type="many_to_one", + foreign_key="ProductKey", + primary_key="ProductKey", + ) + ], + metrics=[ + Metric( + name="Product Sales", + agg="sum", + sql="Amount", + required_models=["Products"], + relationship_overrides=[ + RelationshipOverride( + from_model="Sales", + from_column="ProductKey", + to_model="Products", + to_column="ProductKey", + join_type="inner", + direction="Both", + ) + ], + ) + ], + ) + ) + graph.add_model( + Model( + name="Products", + table="products", + primary_key="ProductKey", + dimensions=[Dimension(name="Category", type="categorical", sql="Category")], + ) + ) + + sql = SQLGenerator(graph).generate(metrics=["Sales.Product Sales"], dimensions=["Products.Category"]) + + assert "INNER JOIN" in sql diff --git a/tests/test_schema_generation.py b/tests/test_schema_generation.py index 0be1baf2..2b7e8b63 100644 --- a/tests/test_schema_generation.py +++ b/tests/test_schema_generation.py @@ -22,6 +22,19 @@ def test_generate_yaml_schema_structure(): assert "Parameter" in defs +def test_generate_yaml_schema_includes_dax_authoring_fields(): + schema = generate_yaml_schema() + + model_props = schema["properties"]["models"]["items"]["properties"] + top_metric_props = schema["properties"]["metrics"]["items"]["properties"] + dimension_props = schema["$defs"]["Dimension"]["properties"] + metric_props = schema["$defs"]["Metric"]["properties"] + + for props in (model_props, top_metric_props, dimension_props, metric_props): + assert props["dax"]["anyOf"][0] == {"type": "string"} + assert props["expression_language"]["anyOf"][0] == {"enum": ["sql", "dax"], "type": "string"} + + def test_export_schema_writes_file(tmp_path): output_path = tmp_path / "schema.json" export_schema(output_path) diff --git a/tests/test_semantic_graph_errors.py b/tests/test_semantic_graph_errors.py index f37e7a57..c026742f 100644 --- a/tests/test_semantic_graph_errors.py +++ b/tests/test_semantic_graph_errors.py @@ -208,3 +208,102 @@ def test_adjacency_built_on_find_path(): path = graph.find_relationship_path("orders", "customers") assert len(path) == 1 assert graph._adjacency_dirty is False + + +def test_one_to_many_path_can_use_tmdl_from_column_override(): + """Imported TMDL relationships preserve explicit fromColumn join keys.""" + from sidemantic.core.relationship import Relationship + + graph = SemanticGraph() + + relationship = Relationship(name="customers", type="one_to_many", foreign_key="product_key") + relationship._tmdl_from_column = "product_key" + products = Model( + name="products", + table="products", + primary_key="internal_product_id", + relationships=[relationship], + ) + customers = Model(name="customers", table="customers", primary_key="customer_id") + + graph.add_model(products) + graph.add_model(customers) + + path = graph.find_relationship_path("products", "customers") + assert [(hop.from_columns, hop.to_columns) for hop in path] == [(["product_key"], ["product_key"])] + + +def test_inactive_relationship_is_not_used_for_default_path(): + """Inactive imported relationships must not participate in normal SQL pathing.""" + from sidemantic.core.relationship import Relationship + + graph = SemanticGraph() + sales = Model( + name="sales", + table="sales", + primary_key="id", + relationships=[ + Relationship( + name="calendar", + type="many_to_one", + foreign_key="ship_date_key", + primary_key="date_key", + active=False, + ) + ], + ) + calendar = Model(name="calendar", table="calendar", primary_key="date_key") + + graph.add_model(sales) + graph.add_model(calendar) + + with pytest.raises(ValueError, match="No join path found"): + graph.find_relationship_path("sales", "calendar") + + +def test_relationship_override_can_activate_query_local_path(): + """Metric-local overrides provide a join path without reactivating the graph edge.""" + from sidemantic.core.relationship import Relationship, RelationshipOverride + + graph = SemanticGraph() + sales = Model( + name="sales", + table="sales", + primary_key="id", + relationships=[ + Relationship( + name="calendar", + type="many_to_one", + foreign_key="order_date_key", + primary_key="date_key", + ), + Relationship( + name="calendar", + type="many_to_one", + foreign_key="ship_date_key", + primary_key="date_key", + active=False, + ), + ], + ) + calendar = Model(name="calendar", table="calendar", primary_key="date_key") + + graph.add_model(sales) + graph.add_model(calendar) + + default_path = graph.find_relationship_path("sales", "calendar") + override_path = graph.find_relationship_path( + "sales", + "calendar", + [ + RelationshipOverride( + from_model="sales", + from_column="ship_date_key", + to_model="calendar", + to_column="date_key", + ) + ], + ) + + assert [(hop.from_columns, hop.to_columns) for hop in default_path] == [(["order_date_key"], ["date_key"])] + assert [(hop.from_columns, hop.to_columns) for hop in override_path] == [(["ship_date_key"], ["date_key"])] diff --git a/tests/test_sidemantic_adapter_metadata.py b/tests/test_sidemantic_adapter_metadata.py new file mode 100644 index 00000000..e6038ebc --- /dev/null +++ b/tests/test_sidemantic_adapter_metadata.py @@ -0,0 +1,73 @@ +"""Tests for native Sidemantic YAML relationship metadata.""" + +import yaml + +from sidemantic.adapters.sidemantic import SidemanticAdapter +from sidemantic.core.introspection import describe_graph + + +def test_native_adapter_round_trips_relationship_activity_and_metric_overrides(tmp_path): + source = tmp_path / "models.yml" + source.write_text( + """ +models: + - name: Sales + table: sales + primary_key: SalesKey + relationships: + - name: Calendar + type: many_to_one + foreign_key: ShipDateKey + primary_key: DateKey + active: false + metrics: + - name: Ship Sales + agg: sum + sql: Amount + required_models: + - Calendar + relationship_overrides: + - from_model: Sales + from_column: ShipDateKey + to_model: Calendar + to_column: DateKey + join_type: inner + direction: Both + - name: Calendar + table: calendar + primary_key: DateKey +""" + ) + + graph = SidemanticAdapter(lower_dax=False).parse(source) + sales = graph.models["Sales"] + relationship = sales.relationships[0] + metric = sales.get_metric("Ship Sales") + + assert relationship.active is False + assert metric.required_models == ["Calendar"] + assert len(metric.relationship_overrides) == 1 + assert metric.relationship_overrides[0].from_column == "ShipDateKey" + + description = describe_graph(graph, model_names=["Sales"]) + metric_info = description["models"][0]["metrics"][0] + assert metric_info["relationship_overrides"] == [ + { + "from_model": "Sales", + "from_column": "ShipDateKey", + "to_model": "Calendar", + "to_column": "DateKey", + "join_type": "inner", + "direction": "Both", + } + ] + + exported_path = tmp_path / "exported.yml" + SidemanticAdapter(lower_dax=False).export(graph, exported_path) + exported = yaml.safe_load(exported_path.read_text()) + + exported_sales = exported["models"][0] + assert exported_sales["relationships"][0]["active"] is False + exported_metric = exported_sales["metrics"][0] + assert exported_metric["required_models"] == ["Calendar"] + assert exported_metric["relationship_overrides"][0]["from_column"] == "ShipDateKey" diff --git a/uv.lock b/uv.lock index e166df71..5e31ec28 100644 --- a/uv.lock +++ b/uv.lock @@ -3310,6 +3310,9 @@ databricks = [ { name = "databricks-sql-connector" }, { name = "pyarrow" }, ] +dax = [ + { name = "sidemantic-dax" }, +] dev = [ { name = "antlr4-python3-runtime" }, { name = "fakesnow" }, @@ -3337,6 +3340,7 @@ full = [ { name = "plotext" }, { name = "pyarrow" }, { name = "pygls" }, + { name = "sidemantic-dax" }, { name = "textual", extra = ["syntax"] }, { name = "textual-plotext" }, { name = "uvicorn" }, @@ -3457,7 +3461,8 @@ requires-dist = [ { name = "riffq", marker = "extra == 'serve'", specifier = ">=0.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" }, { name = "sidemantic", extras = ["postgres", "bigquery", "snowflake", "clickhouse", "databricks", "spark", "adbc"], marker = "extra == 'all-databases'" }, - { name = "sidemantic", extras = ["workbench", "mcp", "apps", "charts", "lsp", "lookml", "malloy", "metricflow", "widget", "api"], marker = "extra == 'full'" }, + { name = "sidemantic", extras = ["workbench", "mcp", "apps", "charts", "lsp", "dax", "lookml", "malloy", "metricflow", "widget", "api"], marker = "extra == 'full'" }, + { name = "sidemantic-dax", marker = "extra == 'dax'", directory = "crates/dax-pyo3" }, { name = "snowflake-connector-python", marker = "extra == 'snowflake'", specifier = ">=3.0.0" }, { name = "sqlglot", specifier = "==27.12.0" }, { name = "textual", extras = ["syntax"], marker = "extra == 'workbench'", specifier = ">=1.0.0" }, @@ -3469,7 +3474,7 @@ requires-dist = [ { name = "uvicorn", marker = "extra == 'dev'", specifier = ">=0.34.0" }, { name = "vl-convert-python", marker = "extra == 'charts'", specifier = ">=1.0.0" }, ] -provides-extras = ["dev", "workbench", "mcp", "apps", "charts", "serve", "api", "postgres", "bigquery", "snowflake", "clickhouse", "databricks", "spark", "adbc", "lsp", "lookml", "malloy", "metricflow", "widget", "all-databases", "full"] +provides-extras = ["dev", "workbench", "mcp", "apps", "charts", "serve", "api", "postgres", "bigquery", "snowflake", "clickhouse", "databricks", "spark", "adbc", "lsp", "dax", "lookml", "malloy", "metricflow", "widget", "all-databases", "full"] [package.metadata.requires-dev] dev = [ @@ -3492,6 +3497,11 @@ dev = [ { name = "uvicorn", specifier = ">=0.34.0" }, ] +[[package]] +name = "sidemantic-dax" +version = "0.1.0" +source = { directory = "crates/dax-pyo3" } + [[package]] name = "six" version = "1.17.0" From 8a5a8c16dee563a231766ffb49f0125356572f97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 May 2026 02:37:02 +0000 Subject: [PATCH 2/9] Auto-update JSON schema --- sidemantic-schema.json | 6760 +++++++++++++++++++++------------------- 1 file changed, 3562 insertions(+), 3198 deletions(-) diff --git a/sidemantic-schema.json b/sidemantic-schema.json index 1d277a19..d8644bb2 100644 --- a/sidemantic-schema.json +++ b/sidemantic-schema.json @@ -1,580 +1,1523 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Sidemantic Semantic Layer", - "description": "Schema for Sidemantic semantic layer YAML configuration", - "type": "object", - "properties": { - "models": { - "type": "array", - "description": "Model definitions", - "items": { - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "name": { - "description": "Unique dimension name within model", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Dimension type", - "enum": [ - "categorical", - "time", - "boolean", - "numeric" - ], - "title": "Type", + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique dimension name within model", + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" + }, + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" + }, + "supported_granularities": { + "anyOf": [ + { + "items": { "type": "string" }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" - }, - "supported_granularities": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "parent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" - } + "type": "array" }, - "required": [ - "name", - "type" - ], - "title": "Dimension", - "type": "object" + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" + }, + "type": { + "description": "Dimension type", + "enum": [ + "categorical", + "time", + "boolean", + "numeric" + ], + "title": "Type", + "type": "string" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" + } + }, + "required": [ + "name", + "type" + ], + "title": "Dimension", + "type": "object" + }, + "Index": { + "description": "Index definition for pre-aggregation performance.", + "properties": { + "columns": { + "description": "Columns to index", + "items": { + "type": "string" }, - "Index": { - "description": "Index definition for pre-aggregation performance.", - "properties": { - "name": { - "description": "Index name", - "title": "Name", - "type": "string" - }, - "columns": { - "description": "Columns to index", - "items": { + "title": "Columns", + "type": "array" + }, + "name": { + "description": "Index name", + "title": "Name", + "type": "string" + }, + "type": { + "default": "regular", + "description": "Index type", + "enum": [ + "regular", + "aggregate" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "columns" + ], + "title": "Index", + "type": "object" + }, + "Metric": { + "$defs": { + "RelationshipOverride": { + "description": "Overrides join behavior for a relationship in a metric/query context.", + "properties": { + "direction": { + "anyOf": [ + { "type": "string" }, - "title": "Columns", - "type": "array" - }, - "type": { - "default": "regular", - "description": "Index type", - "enum": [ - "regular", - "aggregate" - ], - "title": "Type", - "type": "string" - } + { + "type": "null" + } + ], + "default": null, + "description": "Optional filter direction hint", + "title": "Direction" }, - "required": [ - "name", - "columns" - ], - "title": "Index", - "type": "object" - }, - "Metric": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique measure name", - "title": "Name", + "from_column": { + "description": "Source model column", + "title": "From Column", + "type": "string" + }, + "from_model": { + "description": "Source model name", + "title": "From Model", + "type": "string" + }, + "join_type": { + "anyOf": [ + { + "enum": [ + "inner", + "left", + "right", + "full" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional join type override", + "title": "Join Type" + }, + "to_column": { + "description": "Target model column", + "title": "To Column", + "type": "string" + }, + "to_model": { + "description": "Target model name", + "title": "To Model", + "type": "string" + } + }, + "required": [ + "from_model", + "from_column", + "to_model", + "to_column" + ], + "title": "RelationshipOverride", + "type": "object" + } + }, + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "base_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio", + "previous_value" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "cohort_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" + }, + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "drill_fields": { + "anyOf": [ + { + "items": { "type": "string" }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" + }, + "entity": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" }, - "relationship_overrides": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RelationshipOverride" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Relationship overrides for this metric", - "title": "Relationship Overrides" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "filters": { + "anyOf": [ + { + "items": { + "type": "string" }, - "required_models": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Additional models required for this metric", - "title": "Required Models" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" + }, + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" + }, + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "relationship_overrides": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/RelationshipOverride" }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Relationship overrides for this metric", + "title": "Relationship Overrides" + }, + "required_models": { + "anyOf": [ + { + "items": { + "type": "string" }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Additional models required for this metric", + "title": "Required Models" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "steps": { + "anyOf": [ + { + "items": { + "type": "string" }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "window_expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" + }, + "window_frame": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" + }, + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "Parameter": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" + }, + "PreAggregation": { + "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", + "properties": { + "build_range_end": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for end of data range to aggregate", + "title": "Build Range End" + }, + "build_range_start": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for start of data range to aggregate", + "title": "Build Range Start" + }, + "dimensions": { + "anyOf": [ + { + "items": { + "type": "string" }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to group by (e.g., ['status', 'region'])", + "title": "Dimensions" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for aggregation", + "title": "Granularity" + }, + "indexes": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Index" }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Index definitions for query performance", + "title": "Indexes" + }, + "measures": { + "anyOf": [ + { + "items": { + "type": "string" }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", + "title": "Measures" + }, + "name": { + "description": "Unique pre-aggregation name", + "title": "Name", + "type": "string" + }, + "partition_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Partition size for incremental refresh", + "title": "Partition Granularity" + }, + "refresh_key": { + "anyOf": [ + { + "$ref": "#/$defs/RefreshKey" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh strategy configuration" + }, + "scheduled_refresh": { + "default": true, + "description": "Whether to enable scheduled refresh", + "title": "Scheduled Refresh", + "type": "boolean" + }, + "time_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time dimension for temporal grouping", + "title": "Time Dimension" + }, + "type": { + "default": "rollup", + "description": "Pre-aggregation type", + "enum": [ + "rollup", + "original_sql", + "rollup_join", + "lambda" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PreAggregation", + "type": "object" + }, + "RefreshKey": { + "description": "Refresh strategy configuration for pre-aggregations.", + "properties": { + "every": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", + "title": "Every" + }, + "incremental": { + "default": false, + "description": "Whether to use incremental refresh (only update changed partitions)", + "title": "Incremental", + "type": "boolean" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL query that returns a value to trigger refresh when changed", + "title": "Sql" + }, + "update_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", + "title": "Update Window" + } + }, + "title": "RefreshKey", + "type": "object" + }, + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", + "properties": { + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" + }, + "foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Name of the related model", + "title": "Name", + "type": "string" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" }, - "calculation": { + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" + }, + "related_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" + }, + "through": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Junction model for many_to_many relationships", + "title": "Through" + }, + "through_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" + }, + "type": { + "description": "Type of relationship", + "enum": [ + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "RelationshipOverride": { + "description": "Overrides join behavior for a relationship in a metric/query context.", + "properties": { + "direction": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional filter direction hint", + "title": "Direction" + }, + "from_column": { + "description": "Source model column", + "title": "From Column", + "type": "string" + }, + "from_model": { + "description": "Source model name", + "title": "From Model", + "type": "string" + }, + "join_type": { + "anyOf": [ + { + "enum": [ + "inner", + "left", + "right", + "full" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional join type override", + "title": "Join Type" + }, + "to_column": { + "description": "Target model column", + "title": "To Column", + "type": "string" + }, + "to_model": { + "description": "Target model name", + "title": "To Model", + "type": "string" + } + }, + "required": [ + "from_model", + "from_column", + "to_model", + "to_column" + ], + "title": "RelationshipOverride", + "type": "object" + }, + "Segment": { + "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "name": { + "description": "Unique segment name", + "title": "Name", + "type": "string" + }, + "public": { + "default": true, + "description": "Whether segment is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "sql": { + "description": "SQL WHERE clause expression", + "title": "Sql", + "type": "string" + } + }, + "required": [ + "name", + "sql" + ], + "title": "Segment", + "type": "object" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Schema for Sidemantic semantic layer YAML configuration", + "properties": { + "metrics": { + "description": "Top-level metric definitions (optional - can also define in models)", + "items": { + "$defs": { + "RelationshipOverride": { + "description": "Overrides join behavior for a relationship in a metric/query context.", + "properties": { + "direction": { "anyOf": [ { - "enum": [ - "difference", - "percent_change", - "ratio", - "previous_value" - ], "type": "string" }, { @@ -582,38 +1525,28 @@ } ], "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" + "description": "Optional filter direction hint", + "title": "Direction" }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" + "from_column": { + "description": "Source model column", + "title": "From Column", + "type": "string" }, - "base_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" + "from_model": { + "description": "Source model name", + "title": "From Model", + "type": "string" }, - "conversion_event": { + "join_type": { "anyOf": [ { + "enum": [ + "inner", + "left", + "right", + "full" + ], "type": "string" }, { @@ -621,774 +1554,389 @@ } ], "default": null, - "description": "Target event filter", - "title": "Conversion Event" + "description": "Optional join type override", + "title": "Join Type" }, - "conversion_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" + "to_column": { + "description": "Target model column", + "title": "To Column", + "type": "string" }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" + "to_model": { + "description": "Target model name", + "title": "To Model", + "type": "string" + } + }, + "required": [ + "from_model", + "from_column", + "to_model", + "to_column" + ], + "title": "RelationshipOverride", + "type": "object" + } + }, + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "activity_event": { + "anyOf": [ + { + "type": "string" }, - "cohort_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "activity_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" + "type": "string" }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "base_event": { + "anyOf": [ + { + "type": "string" }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio", + "previous_value" ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" + "type": "string" }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "cohort_event": { + "anyOf": [ + { + "type": "string" }, - "having": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" + "type": "string" }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "conversion_event": { + "anyOf": [ + { + "type": "string" }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "dax": { + "anyOf": [ + { + "type": "string" }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" + }, + "denominator": { + "anyOf": [ + { + "type": "string" }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" + }, + "entity": { + "anyOf": [ + { + "type": "string" }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" + "type": "string" }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" + { + "type": "null" } - }, - "required": [ - "name" ], - "title": "Metric", - "type": "object" + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" }, - "PreAggregation": { - "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", - "properties": { - "name": { - "description": "Unique pre-aggregation name", - "title": "Name", + "extends": { + "anyOf": [ + { "type": "string" }, - "type": { - "default": "rollup", - "description": "Pre-aggregation type", - "enum": [ - "rollup", - "original_sql", - "rollup_join", - "lambda" - ], - "title": "Type", - "type": "string" + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" }, - "measures": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", - "title": "Measures" + { + "type": "number" }, - "dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to group by (e.g., ['status', 'region'])", - "title": "Dimensions" - }, - "time_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time dimension for temporal grouping", - "title": "Time Dimension" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for aggregation", - "title": "Granularity" - }, - "partition_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Partition size for incremental refresh", - "title": "Partition Granularity" - }, - "refresh_key": { - "anyOf": [ - { - "$ref": "#/$defs/RefreshKey" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh strategy configuration" - }, - "scheduled_refresh": { - "default": true, - "description": "Whether to enable scheduled refresh", - "title": "Scheduled Refresh", - "type": "boolean" - }, - "indexes": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Index" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Index definitions for query performance", - "title": "Indexes" - }, - "build_range_start": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for start of data range to aggregate", - "title": "Build Range Start" + { + "type": "string" }, - "build_range_end": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for end of data range to aggregate", - "title": "Build Range End" + { + "type": "null" } - }, - "required": [ - "name" ], - "title": "PreAggregation", - "type": "object" + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" }, - "RefreshKey": { - "description": "Refresh strategy configuration for pre-aggregations.", - "properties": { - "every": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", - "title": "Every" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL query that returns a value to trigger refresh when changed", - "title": "Sql" - }, - "incremental": { - "default": false, - "description": "Whether to use incremental refresh (only update changed partitions)", - "title": "Incremental", - "type": "boolean" + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "update_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", - "title": "Update Window" + { + "type": "null" } - }, - "title": "RefreshKey", - "type": "object" + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "name": { - "description": "Name of the related model", - "title": "Name", + "format": { + "anyOf": [ + { "type": "string" }, - "type": { - "description": "Type of relationship", + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "grain_to_date": { + "anyOf": [ + { "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" + "day", + "week", + "month", + "quarter", + "year" ], - "title": "Type", "type": "string" }, - "foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "having": { + "anyOf": [ + { + "type": "string" }, - "primary_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "active": { - "default": true, - "description": "Whether the relationship is active by default", - "title": "Active", - "type": "boolean" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - } - }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" - }, - "RelationshipOverride": { - "description": "Overrides join behavior for a relationship in a metric/query context.", - "properties": { - "from_model": { - "description": "Source model name", - "title": "From Model", - "type": "string" - }, - "from_column": { - "description": "Source model column", - "title": "From Column", - "type": "string" - }, - "to_model": { - "description": "Target model name", - "title": "To Model", - "type": "string" - }, - "to_column": { - "description": "Target model column", - "title": "To Column", - "type": "string" - }, - "join_type": { - "anyOf": [ - { - "enum": [ - "inner", - "left", - "right", - "full" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional join type override", - "title": "Join Type" - }, - "direction": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional filter direction hint", - "title": "Direction" - } - }, - "required": [ - "from_model", - "from_column", - "to_model", - "to_column" - ], - "title": "RelationshipOverride", - "type": "object" - }, - "Segment": { - "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", - "properties": { - "name": { - "description": "Unique segment name", - "title": "Name", - "type": "string" - }, - "sql": { - "description": "SQL WHERE clause expression", - "title": "Sql", - "type": "string" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "public": { - "default": true, - "description": "Whether segment is visible in API/UI", - "title": "Public", - "type": "boolean" + { + "type": "null" } - }, - "required": [ - "name", - "sql" ], - "title": "Segment", - "type": "object" - } - }, - "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique model name", - "title": "Name", - "type": "string" + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" }, - "table": { + "inner_metrics": { "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Physical table name (schema.table)", - "title": "Table" + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" }, - "sql": { + "label": { "anyOf": [ { "type": "string" @@ -1398,40 +1946,43 @@ } ], "default": null, - "description": "SQL expression for derived tables", - "title": "Sql" + "description": "Display label", + "title": "Label" }, - "dax": { + "meta": { "anyOf": [ { - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], "default": null, - "description": "DAX table expression source text to lower into SQL", - "title": "Dax" + "description": "Arbitrary metadata for extensions", + "title": "Meta" }, - "expression_language": { + "metadata": { "anyOf": [ { - "enum": [ - "sql", - "dax" - ], - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], "default": null, - "description": "Expression language for sql/dax derived table authoring", - "title": "Expression Language" + "description": "Adapter-specific metadata payload", + "title": "Metadata" }, - "source_uri": { + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "non_additive_dimension": { "anyOf": [ { "type": "string" @@ -1441,10 +1992,10 @@ } ], "default": null, - "description": "Remote data source URI (e.g., https://, s3://, gs://)", - "title": "Source Uri" + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" }, - "description": { + "numerator": { "anyOf": [ { "type": "string" @@ -1454,10 +2005,10 @@ } ], "default": null, - "description": "Human-readable description", - "title": "Description" + "description": "Numerator measure for ratio", + "title": "Numerator" }, - "extends": { + "offset_window": { "anyOf": [ { "type": "string" @@ -1467,55 +2018,49 @@ } ], "default": null, - "description": "Parent model to inherit from", - "title": "Extends" + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" }, - "metadata": { + "periods": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "type": "integer" }, { "type": "null" } ], "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" }, - "relationships": { - "description": "Relationships to other models", - "items": { - "$ref": "#/$defs/Relationship" - }, - "title": "Relationships", - "type": "array" + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" }, - "primary_key": { + "relationship_overrides": { "anyOf": [ - { - "type": "string" - }, { "items": { - "type": "string" + "$ref": "#/$defs/RelationshipOverride" }, "type": "array" + }, + { + "type": "null" } ], - "default": "id", - "description": "Primary key column(s)", - "title": "Primary Key" + "default": null, + "description": "Relationship overrides for this metric", + "title": "Relationship Overrides" }, - "unique_keys": { + "required_models": { "anyOf": [ { "items": { - "items": { - "type": "string" - }, - "type": "array" + "type": "string" }, "type": "array" }, @@ -1524,44 +2069,17 @@ } ], "default": null, - "description": "Unique key constraints (each is a list of columns)", - "title": "Unique Keys" - }, - "dimensions": { - "description": "Dimension definitions", - "items": { - "$ref": "#/$defs/Dimension" - }, - "title": "Dimensions", - "type": "array" - }, - "metrics": { - "description": "Measure definitions", - "items": { - "$ref": "#/$defs/Metric" - }, - "title": "Metrics", - "type": "array" - }, - "segments": { - "description": "Segment (named filter) definitions", - "items": { - "$ref": "#/$defs/Segment" - }, - "title": "Segments", - "type": "array" - }, - "pre_aggregations": { - "description": "Pre-aggregation definitions for query optimization", - "items": { - "$ref": "#/$defs/PreAggregation" - }, - "title": "Pre Aggregations", - "type": "array" + "description": "Additional models required for this metric", + "title": "Required Models" }, - "default_time_dimension": { + "retention_granularity": { "anyOf": [ { + "enum": [ + "day", + "week", + "month" + ], "type": "string" }, { @@ -1569,22 +2087,12 @@ } ], "default": null, - "description": "Default time dimension for metrics (auto-included in queries)", - "title": "Default Time Dimension" + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" }, - "default_grain": { + "sql": { "anyOf": [ { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], "type": "string" }, { @@ -1592,116 +2100,26 @@ } ], "default": null, - "description": "Default time granularity when using default_time_dimension", - "title": "Default Grain" - }, - "auto_dimensions": { - "default": false, - "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", - "title": "Auto Dimensions", - "type": "boolean" + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" }, - "meta": { + "steps": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - } - }, - "required": [ - "name" - ], - "title": "Model", - "type": "object" - } - }, - "metrics": { - "type": "array", - "description": "Top-level metric definitions (optional - can also define in models)", - "items": { - "$defs": { - "RelationshipOverride": { - "description": "Overrides join behavior for a relationship in a metric/query context.", - "properties": { - "from_model": { - "description": "Source model name", - "title": "From Model", - "type": "string" - }, - "from_column": { - "description": "Source model column", - "title": "From Column", - "type": "string" - }, - "to_model": { - "description": "Target model name", - "title": "To Model", - "type": "string" - }, - "to_column": { - "description": "Target model column", - "title": "To Column", - "type": "string" - }, - "join_type": { - "anyOf": [ - { - "enum": [ - "inner", - "left", - "right", - "full" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional join type override", - "title": "Join Type" - }, - "direction": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional filter direction hint", - "title": "Direction" - } - }, - "required": [ - "from_model", - "from_column", - "to_model", - "to_column" - ], - "title": "RelationshipOverride", - "type": "object" - } - }, - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" }, - "extends": { + "time_offset": { "anyOf": [ { "type": "string" @@ -1711,24 +2129,20 @@ } ], "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" }, - "agg": { + "type": { "anyOf": [ { "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" ], "type": "string" }, @@ -1737,10 +2151,10 @@ } ], "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" + "description": "Metric type for complex calculations", + "title": "Type" }, - "sql": { + "value_format_name": { "anyOf": [ { "type": "string" @@ -1750,133 +2164,10 @@ } ], "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "relationship_overrides": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RelationshipOverride" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Relationship overrides for this metric", - "title": "Relationship Overrides" - }, - "required_models": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Additional models required for this metric", - "title": "Required Models" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" - }, - "window": { + "window": { "anyOf": [ { "type": "string" @@ -1889,26 +2180,6 @@ "description": "Time window for cumulative (e.g., '7 days')", "title": "Window" }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, "window_expression": { "anyOf": [ { @@ -1947,1629 +2218,1722 @@ "default": null, "description": "Window ORDER BY column (defaults to model's default_time_dimension)", "title": "Window Order" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "type": "array" + }, + "models": { + "description": "Model definitions", + "items": { + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio", - "previous_value" + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "entity": { - "anyOf": [ - { - "type": "string" + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "base_event": { - "anyOf": [ - { - "type": "string" + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "conversion_event": { - "anyOf": [ - { + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique dimension name within model", + "title": "Name", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" + }, + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" + }, + "supported_granularities": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" + }, + "type": { + "description": "Dimension type", + "enum": [ + "categorical", + "time", + "boolean", + "numeric" + ], + "title": "Type", "type": "string" }, - { - "type": "null" + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" } + }, + "required": [ + "name", + "type" ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" + "title": "Dimension", + "type": "object" }, - "steps": { - "anyOf": [ - { + "Index": { + "description": "Index definition for pre-aggregation performance.", + "properties": { + "columns": { + "description": "Columns to index", "items": { "type": "string" }, + "title": "Columns", "type": "array" }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "cohort_event": { - "anyOf": [ - { + "name": { + "description": "Index name", + "title": "Name", "type": "string" }, - { - "type": "null" + "type": { + "default": "regular", + "description": "Index type", + "enum": [ + "regular", + "aggregate" + ], + "title": "Type", + "type": "string" } + }, + "required": [ + "name", + "columns" ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" + "title": "Index", + "type": "object" }, - "activity_event": { - "anyOf": [ - { - "type": "string" + "Metric": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "periods": { - "anyOf": [ - { - "type": "integer" + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" + "base_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Starting event filter", + "title": "Base Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" - }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio", + "previous_value" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "having": { - "anyOf": [ - { - "type": "string" + "cohort_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" - }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" }, - { - "type": "number" + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" }, - { - "type": "string" + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text to lower into SQL", + "title": "Dax" }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "description": { - "anyOf": [ - { - "type": "string" + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" + "entity": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - } - }, - "parameters": { - "type": "array", - "description": "Parameter definitions for dynamic queries", - "items": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - }, - "description": { - "anyOf": [ - { - "type": "string" + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" - }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - } - }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" - } - } - }, - "required": [ - "models" - ], - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "name": { - "description": "Unique dimension name within model", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Dimension type", - "enum": [ - "categorical", - "time", - "boolean", - "numeric" - ], - "title": "Type", - "type": "string" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" - }, - "supported_granularities": { - "anyOf": [ - { - "items": { - "type": "string" + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "parent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" - } - }, - "required": [ - "name", - "type" - ], - "title": "Dimension", - "type": "object" - }, - "Metric": { - "$defs": { - "RelationshipOverride": { - "description": "Overrides join behavior for a relationship in a metric/query context.", - "properties": { - "from_model": { - "description": "Source model name", - "title": "From Model", - "type": "string" - }, - "from_column": { - "description": "Source model column", - "title": "From Column", - "type": "string" - }, - "to_model": { - "description": "Target model name", - "title": "To Model", - "type": "string" - }, - "to_column": { - "description": "Target model column", - "title": "To Column", - "type": "string" - }, - "join_type": { - "anyOf": [ - { - "enum": [ - "inner", - "left", - "right", - "full" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional join type override", - "title": "Join Type" - }, - "direction": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional filter direction hint", - "title": "Direction" - } - }, - "required": [ - "from_model", - "from_column", - "to_model", - "to_column" - ], - "title": "RelationshipOverride", - "type": "object" - } - }, - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "relationship_overrides": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RelationshipOverride" + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" + }, + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "relationship_overrides": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/RelationshipOverride" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Relationship overrides for this metric", + "title": "Relationship Overrides" + }, + "required_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Additional models required for this metric", + "title": "Required Models" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "steps": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Relationship overrides for this metric", - "title": "Relationship Overrides" - }, - "required_models": { - "anyOf": [ - { - "items": { - "type": "string" + "window_expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Additional models required for this metric", - "title": "Required Models" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" - }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" - }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio", - "previous_value" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "base_event": { - "anyOf": [ - { - "type": "string" + "window_frame": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" + }, + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "PreAggregation": { + "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", + "properties": { + "build_range_end": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for end of data range to aggregate", + "title": "Build Range End" + }, + "build_range_start": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for start of data range to aggregate", + "title": "Build Range Start" + }, + "dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to group by (e.g., ['status', 'region'])", + "title": "Dimensions" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for aggregation", + "title": "Granularity" + }, + "indexes": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Index" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Index definitions for query performance", + "title": "Indexes" + }, + "measures": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", + "title": "Measures" + }, + "name": { + "description": "Unique pre-aggregation name", + "title": "Name", + "type": "string" + }, + "partition_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Partition size for incremental refresh", + "title": "Partition Granularity" + }, + "refresh_key": { + "anyOf": [ + { + "$ref": "#/$defs/RefreshKey" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh strategy configuration" + }, + "scheduled_refresh": { + "default": true, + "description": "Whether to enable scheduled refresh", + "title": "Scheduled Refresh", + "type": "boolean" + }, + "time_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time dimension for temporal grouping", + "title": "Time Dimension" + }, + "type": { + "default": "rollup", + "description": "Pre-aggregation type", + "enum": [ + "rollup", + "original_sql", + "rollup_join", + "lambda" + ], + "title": "Type", + "type": "string" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" + "required": [ + "name" + ], + "title": "PreAggregation", + "type": "object" + }, + "RefreshKey": { + "description": "Refresh strategy configuration for pre-aggregations.", + "properties": { + "every": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", + "title": "Every" + }, + "incremental": { + "default": false, + "description": "Whether to use incremental refresh (only update changed partitions)", + "title": "Incremental", + "type": "boolean" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL query that returns a value to trigger refresh when changed", + "title": "Sql" + }, + "update_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", + "title": "Update Window" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "steps": { - "anyOf": [ - { - "items": { + "title": "RefreshKey", + "type": "object" + }, + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", + "properties": { + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" + }, + "foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Name of the related model", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "cohort_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "activity_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" + }, + "related_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" + }, + "through": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Junction model for many_to_many relationships", + "title": "Through" + }, + "through_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" + }, + "type": { + "description": "Type of relationship", + "enum": [ + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" + ], + "title": "Type", + "type": "string" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "RelationshipOverride": { + "description": "Overrides join behavior for a relationship in a metric/query context.", + "properties": { + "direction": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional filter direction hint", + "title": "Direction" + }, + "from_column": { + "description": "Source model column", + "title": "From Column", + "type": "string" + }, + "from_model": { + "description": "Source model name", + "title": "From Model", + "type": "string" + }, + "join_type": { + "anyOf": [ + { + "enum": [ + "inner", + "left", + "right", + "full" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional join type override", + "title": "Join Type" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" - }, - "entity_dimensions": { - "anyOf": [ - { - "items": { + "to_column": { + "description": "Target model column", + "title": "To Column", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "having": { - "anyOf": [ - { - "type": "string" + "to_model": { + "description": "Target model name", + "title": "To Model", + "type": "string" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" - }, - "filters": { - "anyOf": [ - { - "items": { + "required": [ + "from_model", + "from_column", + "to_model", + "to_column" + ], + "title": "RelationshipOverride", + "type": "object" + }, + "Segment": { + "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "name": { + "description": "Unique segment name", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" + "public": { + "default": true, + "description": "Whether segment is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "sql": { + "description": "SQL WHERE clause expression", + "title": "Sql", + "type": "string" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" + "required": [ + "name", + "sql" + ], + "title": "Segment", + "type": "object" + } }, - "drill_fields": { - "anyOf": [ - { - "items": { + "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", + "properties": { + "auto_dimensions": { + "default": false, + "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", + "title": "Auto Dimensions", + "type": "boolean" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX table expression source text to lower into SQL", + "title": "Dax" + }, + "default_grain": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default time granularity when using default_time_dimension", + "title": "Default Grain" + }, + "default_time_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default time dimension for metrics (auto-included in queries)", + "title": "Default Time Dimension" + }, + "description": { + "anyOf": [ + { "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "dimensions": { + "description": "Dimension definitions", + "items": { + "$ref": "#/$defs/Dimension" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "title": "Dimensions", + "type": "array" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/dax derived table authoring", + "title": "Expression Language" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent model to inherit from", + "title": "Extends" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "metrics": { + "description": "Measure definitions", + "items": { + "$ref": "#/$defs/Metric" }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "name": { - "description": "Name of the related model", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Type of relationship", - "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" - ], - "title": "Type", - "type": "string" - }, - "foreign_key": { - "anyOf": [ - { - "type": "string" + "title": "Metrics", + "type": "array" + }, + "name": { + "description": "Unique model name", + "title": "Name", + "type": "string" + }, + "pre_aggregations": { + "description": "Pre-aggregation definitions for query optimization", + "items": { + "$ref": "#/$defs/PreAggregation" }, - { - "items": { + "title": "Pre Aggregations", + "type": "array" + }, + "primary_key": { + "anyOf": [ + { "type": "string" }, - "type": "array" + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "id", + "description": "Primary key column(s)", + "title": "Primary Key" + }, + "relationships": { + "description": "Relationships to other models", + "items": { + "$ref": "#/$defs/Relationship" }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" - }, - "primary_key": { - "anyOf": [ - { - "type": "string" + "title": "Relationships", + "type": "array" + }, + "segments": { + "description": "Segment (named filter) definitions", + "items": { + "$ref": "#/$defs/Segment" }, - { - "items": { + "title": "Segments", + "type": "array" + }, + "source_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Remote data source URI (e.g., https://, s3://, gs://)", + "title": "Source Uri" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for derived tables", + "title": "Sql" + }, + "table": { + "anyOf": [ + { "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "active": { - "default": true, - "description": "Whether the relationship is active by default", - "title": "Active", - "type": "boolean" + { + "type": "null" + } + ], + "default": null, + "description": "Physical table name (schema.table)", + "title": "Table" + }, + "unique_keys": { + "anyOf": [ + { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Unique key constraints (each is a list of columns)", + "title": "Unique Keys" + } }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - } + "required": [ + "name" + ], + "title": "Model", + "type": "object" }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" + "type": "array" }, - "Parameter": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" + "parameters": { + "description": "Parameter definitions for dynamic queries", + "items": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + } }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - } + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" + "type": "array" } - } + }, + "required": [ + "models" + ], + "title": "Sidemantic Semantic Layer", + "type": "object" } \ No newline at end of file From 73d82e8a19f0df18a439cd3c4f0922d2d2b78e31 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Thu, 14 May 2026 07:39:13 -0700 Subject: [PATCH 3/9] Narrow DAX and TMDL PR scope --- .github/workflows/ci.yml | 2 - .github/workflows/publish.yml | 2 - README.md | 14 +- sidemantic-schema.json | 6462 +++++++-------- sidemantic/adapters/sidemantic.py | 40 +- sidemantic/adapters/tmdl.py | 258 +- sidemantic/cli.py | 178 +- sidemantic/core/dimension.py | 2 +- sidemantic/core/introspection.py | 26 +- sidemantic/core/metric.py | 9 +- sidemantic/core/model.py | 2 +- sidemantic/core/relationship.py | 13 - sidemantic/core/semantic_graph.py | 93 +- sidemantic/core/semantic_layer.py | 121 - sidemantic/dax/__init__.py | 33 - sidemantic/dax/modeling.py | 335 - sidemantic/dax/runtime.py | 110 - sidemantic/dax/translator.py | 7367 ----------------- sidemantic/loaders.py | 19 +- sidemantic/sql/generator.py | 337 +- sidemantic/validation.py | 6 +- .../tmdl/test_external_tmdl_fixtures.py | 8 +- tests/adapters/tmdl/test_parsing.py | 148 +- tests/dax/test_model_authoring.py | 748 -- tests/dax/test_query_translation.py | 7081 ---------------- tests/dax/test_runtime.py | 138 - tests/dax/test_translation.py | 6070 -------------- tests/test_cli_commands.py | 258 - tests/test_relationship_override_sql.py | 114 - tests/test_semantic_graph_errors.py | 48 - tests/test_sidemantic_adapter_metadata.py | 73 - tests/test_validation.py | 4 +- 32 files changed, 3029 insertions(+), 27090 deletions(-) delete mode 100644 sidemantic/dax/__init__.py delete mode 100644 sidemantic/dax/modeling.py delete mode 100644 sidemantic/dax/runtime.py delete mode 100644 sidemantic/dax/translator.py delete mode 100644 tests/dax/test_model_authoring.py delete mode 100644 tests/dax/test_query_translation.py delete mode 100644 tests/dax/test_runtime.py delete mode 100644 tests/dax/test_translation.py delete mode 100644 tests/test_relationship_override_sql.py delete mode 100644 tests/test_sidemantic_adapter_metadata.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71daa213..e73b9124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,11 +72,9 @@ jobs: SIDEMANTIC_WHEEL=$(realpath "$(find /tmp/sidemantic-dist -name 'sidemantic-[0-9]*.whl' -print -quit)") cd /tmp uv run --no-project --find-links /tmp/sidemantic-dax-dist --with "${SIDEMANTIC_WHEEL}[dax]" -- python - <<'PY' - from sidemantic import SemanticLayer import sidemantic_dax sidemantic_dax.parse_expression("1") - assert SemanticLayer().compile_dax_query('EVALUATE ROW("one", 1)') == "SELECT 1 AS one" PY update-schema: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 63d69d81..d9dc9a2e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,11 +209,9 @@ jobs: SIDEMANTIC_WHEEL=$(realpath "$(find dist -name 'sidemantic-[0-9]*.whl' -print -quit)") cd /tmp uv run --no-project --find-links "${DIST_DIR}" --with "${SIDEMANTIC_WHEEL}[dax]" -- python - <<'PY' - from sidemantic import SemanticLayer import sidemantic_dax sidemantic_dax.parse_expression("1") - assert SemanticLayer().compile_dax_query('EVALUATE ROW("one", 1)') == "SELECT 1 AS one" PY - name: Publish to PyPI diff --git a/README.md b/README.md index a8e96f8e..4f617f42 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ DAX/TMDL support lives behind the `dax` extra because it includes a native Rust uv add "sidemantic[dax]" ``` -Native Sidemantic YAML can define DAX expressions directly. Supported DAX is lowered to executable Sidemantic SQL semantics at load time, while the original DAX is preserved for export and UI metadata: +Native Sidemantic YAML can preserve DAX expression source text for Power BI interoperability: ```yaml models: @@ -151,7 +151,7 @@ models: dax: "SUM('sales'[amount])" ``` -Power BI TMDL projects can be loaded from a project root or `definition/` folder. Embedded DAX measures, calculated columns, calculated tables, relationships, and TMDL passthrough metadata are imported and exposed through model metadata: +Power BI TMDL projects can be loaded from a project root or `definition/` folder. Embedded DAX measures, calculated columns, calculated tables, relationships, and TMDL passthrough metadata are parsed and preserved in model metadata: ```python from sidemantic import SemanticLayer, load_from_directory @@ -169,13 +169,6 @@ from sidemantic.adapters.tmdl import TMDLAdapter TMDLAdapter().export(layer.graph, "exported_tmdl/") ``` -Run DAX `EVALUATE` queries through the CLI: - -```bash -sidemantic dax-query "EVALUATE SUMMARIZECOLUMNS('sales'[category], \"Revenue\", [revenue])" --models models/ --db data.duckdb -sidemantic dax-query "EVALUATE VALUES('sales'[category])" --models models/ --dry-run -``` - ## CLI ```bash @@ -197,9 +190,6 @@ sidemantic validate models/ # Model info sidemantic info models/ -# DAX query -sidemantic dax-query "EVALUATE VALUES('orders'[status])" --models models/ --db data.duckdb - # Pre-aggregation recommendations sidemantic preagg recommend --db data.duckdb diff --git a/sidemantic-schema.json b/sidemantic-schema.json index d8644bb2..6801071e 100644 --- a/sidemantic-schema.json +++ b/sidemantic-schema.json @@ -1,1521 +1,408 @@ { - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Unique dimension name within model", - "title": "Name", - "type": "string" - }, - "parent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" - }, - "supported_granularities": { - "anyOf": [ - { - "items": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Sidemantic Semantic Layer", + "description": "Schema for Sidemantic semantic layer YAML configuration", + "type": "object", + "properties": { + "models": { + "type": "array", + "description": "Model definitions", + "items": { + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "name": { + "description": "Unique dimension name within model", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" - }, - "type": { - "description": "Dimension type", - "enum": [ - "categorical", - "time", - "boolean", - "numeric" - ], - "title": "Type", - "type": "string" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" - } - }, - "required": [ - "name", - "type" - ], - "title": "Dimension", - "type": "object" - }, - "Index": { - "description": "Index definition for pre-aggregation performance.", - "properties": { - "columns": { - "description": "Columns to index", - "items": { - "type": "string" - }, - "title": "Columns", - "type": "array" - }, - "name": { - "description": "Index name", - "title": "Name", - "type": "string" - }, - "type": { - "default": "regular", - "description": "Index type", - "enum": [ - "regular", - "aggregate" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name", - "columns" - ], - "title": "Index", - "type": "object" - }, - "Metric": { - "$defs": { - "RelationshipOverride": { - "description": "Overrides join behavior for a relationship in a metric/query context.", - "properties": { - "direction": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional filter direction hint", - "title": "Direction" - }, - "from_column": { - "description": "Source model column", - "title": "From Column", - "type": "string" - }, - "from_model": { - "description": "Source model name", - "title": "From Model", - "type": "string" + "type": { + "description": "Dimension type", + "enum": [ + "categorical", + "time", + "boolean", + "numeric" + ], + "title": "Type", + "type": "string" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" + }, + "supported_granularities": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" + }, + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" + } }, - "join_type": { - "anyOf": [ - { - "enum": [ - "inner", - "left", - "right", - "full" - ], + "required": [ + "name", + "type" + ], + "title": "Dimension", + "type": "object" + }, + "Index": { + "description": "Index definition for pre-aggregation performance.", + "properties": { + "name": { + "description": "Index name", + "title": "Name", + "type": "string" + }, + "columns": { + "description": "Columns to index", + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional join type override", - "title": "Join Type" - }, - "to_column": { - "description": "Target model column", - "title": "To Column", - "type": "string" + "title": "Columns", + "type": "array" + }, + "type": { + "default": "regular", + "description": "Index type", + "enum": [ + "regular", + "aggregate" + ], + "title": "Type", + "type": "string" + } }, - "to_model": { - "description": "Target model name", - "title": "To Model", - "type": "string" - } + "required": [ + "name", + "columns" + ], + "title": "Index", + "type": "object" }, - "required": [ - "from_model", - "from_column", - "to_model", - "to_column" - ], - "title": "RelationshipOverride", - "type": "object" - } - }, - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "activity_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "base_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio", - "previous_value" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "cohort_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "drill_fields": { - "anyOf": [ - { - "items": { + "Metric": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique measure name", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "having": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" - }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" - }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" - }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "relationship_overrides": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RelationshipOverride" + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Relationship overrides for this metric", - "title": "Relationship Overrides" - }, - "required_models": { - "anyOf": [ - { - "items": { - "type": "string" + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Additional models required for this metric", - "title": "Required Models" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" - }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - }, - "Parameter": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" - }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" - }, - "PreAggregation": { - "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", - "properties": { - "build_range_end": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for end of data range to aggregate", - "title": "Build Range End" - }, - "build_range_start": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for start of data range to aggregate", - "title": "Build Range Start" - }, - "dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to group by (e.g., ['status', 'region'])", - "title": "Dimensions" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for aggregation", - "title": "Granularity" - }, - "indexes": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Index" + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Index definitions for query performance", - "title": "Indexes" - }, - "measures": { - "anyOf": [ - { - "items": { - "type": "string" + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", - "title": "Measures" - }, - "name": { - "description": "Unique pre-aggregation name", - "title": "Name", - "type": "string" - }, - "partition_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Partition size for incremental refresh", - "title": "Partition Granularity" - }, - "refresh_key": { - "anyOf": [ - { - "$ref": "#/$defs/RefreshKey" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh strategy configuration" - }, - "scheduled_refresh": { - "default": true, - "description": "Whether to enable scheduled refresh", - "title": "Scheduled Refresh", - "type": "boolean" - }, - "time_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time dimension for temporal grouping", - "title": "Time Dimension" - }, - "type": { - "default": "rollup", - "description": "Pre-aggregation type", - "enum": [ - "rollup", - "original_sql", - "rollup_join", - "lambda" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name" - ], - "title": "PreAggregation", - "type": "object" - }, - "RefreshKey": { - "description": "Refresh strategy configuration for pre-aggregations.", - "properties": { - "every": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", - "title": "Every" - }, - "incremental": { - "default": false, - "description": "Whether to use incremental refresh (only update changed partitions)", - "title": "Incremental", - "type": "boolean" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL query that returns a value to trigger refresh when changed", - "title": "Sql" - }, - "update_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", - "title": "Update Window" - } - }, - "title": "RefreshKey", - "type": "object" - }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "active": { - "default": true, - "description": "Whether the relationship is active by default", - "title": "Active", - "type": "boolean" - }, - "foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Name of the related model", - "title": "Name", - "type": "string" - }, - "primary_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "type": { - "description": "Type of relationship", - "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" - ], - "title": "Type", - "type": "string" - } - }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" - }, - "RelationshipOverride": { - "description": "Overrides join behavior for a relationship in a metric/query context.", - "properties": { - "direction": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional filter direction hint", - "title": "Direction" - }, - "from_column": { - "description": "Source model column", - "title": "From Column", - "type": "string" - }, - "from_model": { - "description": "Source model name", - "title": "From Model", - "type": "string" - }, - "join_type": { - "anyOf": [ - { - "enum": [ - "inner", - "left", - "right", - "full" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional join type override", - "title": "Join Type" - }, - "to_column": { - "description": "Target model column", - "title": "To Column", - "type": "string" - }, - "to_model": { - "description": "Target model name", - "title": "To Model", - "type": "string" - } - }, - "required": [ - "from_model", - "from_column", - "to_model", - "to_column" - ], - "title": "RelationshipOverride", - "type": "object" - }, - "Segment": { - "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "name": { - "description": "Unique segment name", - "title": "Name", - "type": "string" - }, - "public": { - "default": true, - "description": "Whether segment is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "sql": { - "description": "SQL WHERE clause expression", - "title": "Sql", - "type": "string" - } - }, - "required": [ - "name", - "sql" - ], - "title": "Segment", - "type": "object" - } - }, - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Schema for Sidemantic semantic layer YAML configuration", - "properties": { - "metrics": { - "description": "Top-level metric definitions (optional - can also define in models)", - "items": { - "$defs": { - "RelationshipOverride": { - "description": "Overrides join behavior for a relationship in a metric/query context.", - "properties": { - "direction": { + "offset_window": { "anyOf": [ { "type": "string" @@ -1525,27 +412,31 @@ } ], "default": null, - "description": "Optional filter direction hint", - "title": "Direction" - }, - "from_column": { - "description": "Source model column", - "title": "From Column", - "type": "string" + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" }, - "from_model": { - "description": "Source model name", - "title": "From Model", - "type": "string" + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" }, - "join_type": { + "grain_to_date": { "anyOf": [ { "enum": [ - "inner", - "left", - "right", - "full" + "day", + "week", + "month", + "quarter", + "year" ], "type": "string" }, @@ -1554,720 +445,496 @@ } ], "default": null, - "description": "Optional join type override", - "title": "Join Type" + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" }, - "to_column": { - "description": "Target model column", - "title": "To Column", - "type": "string" + "window_expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" }, - "to_model": { - "description": "Target model name", - "title": "To Model", - "type": "string" - } - }, - "required": [ - "from_model", - "from_column", - "to_model", - "to_column" - ], - "title": "RelationshipOverride", - "type": "object" - } - }, - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "activity_event": { - "anyOf": [ - { - "type": "string" + "window_frame": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "base_event": { - "anyOf": [ - { - "type": "string" + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio", - "previous_value" + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "cohort_event": { - "anyOf": [ - { - "type": "string" + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio" + ], + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" + "entity": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" + "base_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "dax": { - "anyOf": [ - { - "type": "string" + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" - }, - "denominator": { - "anyOf": [ - { - "type": "string" + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "description": { - "anyOf": [ - { - "type": "string" + "steps": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "cohort_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "entity": { - "anyOf": [ - { - "type": "string" + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "extends": { - "anyOf": [ - { - "type": "string" + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" }, - { - "type": "number" + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" }, - { - "type": "string" + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "format": { - "anyOf": [ - { - "type": "string" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Display label", + "title": "Label" }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "having": { - "anyOf": [ - { - "type": "string" + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" - }, - "label": { - "anyOf": [ - { - "type": "string" + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" - }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" }, - { - "type": "null" + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" } + }, + "required": [ + "name" ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" + "title": "Metric", + "type": "object" }, - "numerator": { - "anyOf": [ - { + "PreAggregation": { + "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", + "properties": { + "name": { + "description": "Unique pre-aggregation name", + "title": "Name", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "offset_window": { - "anyOf": [ - { + "type": { + "default": "rollup", + "description": "Pre-aggregation type", + "enum": [ + "rollup", + "original_sql", + "rollup_join", + "lambda" + ], + "title": "Type", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" - }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "relationship_overrides": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RelationshipOverride" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Relationship overrides for this metric", - "title": "Relationship Overrides" - }, - "required_models": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Additional models required for this metric", - "title": "Required Models" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" - }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - }, - "type": "array" - }, - "models": { - "description": "Model definitions", - "items": { - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "dax": { + "measures": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" + "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", + "title": "Measures" }, - "description": { + "dimensions": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Human-readable description", - "title": "Description" + "description": "Dimensions to group by (e.g., ['status', 'region'])", + "title": "Dimensions" }, - "expression_language": { + "time_dimension": { "anyOf": [ { - "enum": [ - "sql", - "dax" - ], "type": "string" }, { @@ -2275,12 +942,20 @@ } ], "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" + "description": "Time dimension for temporal grouping", + "title": "Time Dimension" }, - "format": { + "granularity": { "anyOf": [ { + "enum": [ + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], "type": "string" }, { @@ -2288,16 +963,13 @@ } ], "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" + "description": "Time granularity for aggregation", + "title": "Granularity" }, - "granularity": { + "partition_granularity": { "anyOf": [ { "enum": [ - "second", - "minute", - "hour", "day", "week", "month", @@ -2311,56 +983,44 @@ } ], "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" + "description": "Partition size for incremental refresh", + "title": "Partition Granularity" }, - "label": { + "refresh_key": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/RefreshKey" }, { "type": "null" } ], "default": null, - "description": "Display label", - "title": "Label" + "description": "Refresh strategy configuration" }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" + "scheduled_refresh": { + "default": true, + "description": "Whether to enable scheduled refresh", + "title": "Scheduled Refresh", + "type": "boolean" }, - "metadata": { + "indexes": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "items": { + "$ref": "#/$defs/Index" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Unique dimension name within model", - "title": "Name", - "type": "string" + "description": "Index definitions for query performance", + "title": "Indexes" }, - "parent": { + "build_range_start": { "anyOf": [ { "type": "string" @@ -2370,16 +1030,10 @@ } ], "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" + "description": "SQL expression for start of data range to aggregate", + "title": "Build Range Start" }, - "sql": { + "build_range_end": { "anyOf": [ { "type": "string" @@ -2389,37 +1043,33 @@ } ], "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" - }, - "supported_granularities": { + "description": "SQL expression for end of data range to aggregate", + "title": "Build Range End" + } + }, + "required": [ + "name" + ], + "title": "PreAggregation", + "type": "object" + }, + "RefreshKey": { + "description": "Refresh strategy configuration for pre-aggregations.", + "properties": { + "every": { "anyOf": [ { - "items": { - "type": "string" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" - }, - "type": { - "description": "Dimension type", - "enum": [ - "categorical", - "time", - "boolean", - "numeric" - ], - "title": "Type", - "type": "string" + "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", + "title": "Every" }, - "value_format_name": { + "sql": { "anyOf": [ { "type": "string" @@ -2429,10 +1079,16 @@ } ], "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" + "description": "SQL query that returns a value to trigger refresh when changed", + "title": "Sql" }, - "window": { + "incremental": { + "default": false, + "description": "Whether to use incremental refresh (only update changed partitions)", + "title": "Incremental", + "type": "boolean" + }, + "update_window": { "anyOf": [ { "type": "string" @@ -2442,107 +1098,71 @@ } ], "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" + "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", + "title": "Update Window" } }, - "required": [ - "name", - "type" - ], - "title": "Dimension", + "title": "RefreshKey", "type": "object" }, - "Index": { - "description": "Index definition for pre-aggregation performance.", + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", "properties": { - "columns": { - "description": "Columns to index", - "items": { - "type": "string" - }, - "title": "Columns", - "type": "array" - }, "name": { - "description": "Index name", + "description": "Name of the related model", "title": "Name", "type": "string" }, "type": { - "default": "regular", - "description": "Index type", + "description": "Type of relationship", "enum": [ - "regular", - "aggregate" + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" ], "title": "Type", "type": "string" - } - }, - "required": [ - "name", - "columns" - ], - "title": "Index", - "type": "object" - }, - "Metric": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "activity_event": { + }, + "foreign_key": { "anyOf": [ { "type": "string" }, { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" }, - "base_event": { + "primary_key": { "anyOf": [ { "type": "string" }, + { + "items": { + "type": "string" + }, + "type": "array" + }, { "type": "null" } ], "default": null, - "description": "Starting event filter", - "title": "Base Event" + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" }, - "base_metric": { + "through": { "anyOf": [ { "type": "string" @@ -2552,18 +1172,12 @@ } ], "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" + "description": "Junction model for many_to_many relationships", + "title": "Through" }, - "calculation": { + "through_foreign_key": { "anyOf": [ { - "enum": [ - "difference", - "percent_change", - "ratio", - "previous_value" - ], "type": "string" }, { @@ -2571,10 +1185,10 @@ } ], "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" }, - "cohort_event": { + "related_foreign_key": { "anyOf": [ { "type": "string" @@ -2584,81 +1198,49 @@ } ], "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" }, - "comparison_type": { + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" + }, + "metadata": { "anyOf": [ { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" - ], - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text to lower into SQL", - "title": "Dax" + "description": "Adapter-specific metadata payload", + "title": "Metadata" + } + }, + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "Segment": { + "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", + "properties": { + "name": { + "description": "Unique segment name", + "title": "Name", + "type": "string" }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" + "sql": { + "description": "SQL WHERE clause expression", + "title": "Sql", + "type": "string" }, "description": { "anyOf": [ @@ -2673,1267 +1255,2023 @@ "description": "Human-readable description", "title": "Description" }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" + "public": { + "default": true, + "description": "Whether segment is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name", + "sql" + ], + "title": "Segment", + "type": "object" + } + }, + "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique model name", + "title": "Name", + "type": "string" + }, + "table": { + "anyOf": [ + { + "type": "string" }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" + { + "type": "null" + } + ], + "default": null, + "description": "Physical table name (schema.table)", + "title": "Table" + }, + "sql": { + "anyOf": [ + { + "type": "string" }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for derived tables", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "DAX table expression source text", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" + "type": "string" }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/dax derived table authoring", + "title": "Expression Language" + }, + "source_uri": { + "anyOf": [ + { + "type": "string" }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" + { + "type": "null" + } + ], + "default": null, + "description": "Remote data source URI (e.g., https://, s3://, gs://)", + "title": "Source Uri" + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "extends": { + "anyOf": [ + { + "type": "string" }, - "having": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" + { + "type": "null" + } + ], + "default": null, + "description": "Parent model to inherit from", + "title": "Extends" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "relationships": { + "description": "Relationships to other models", + "items": { + "$ref": "#/$defs/Relationship" + }, + "title": "Relationships", + "type": "array" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" }, - "label": { - "anyOf": [ - { + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "id", + "description": "Primary key column(s)", + "title": "Primary Key" + }, + "unique_keys": { + "anyOf": [ + { + "items": { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" + "type": "array" + }, + "type": "array" }, - "name": { - "description": "Unique measure name", - "title": "Name", + { + "type": "null" + } + ], + "default": null, + "description": "Unique key constraints (each is a list of columns)", + "title": "Unique Keys" + }, + "dimensions": { + "description": "Dimension definitions", + "items": { + "$ref": "#/$defs/Dimension" + }, + "title": "Dimensions", + "type": "array" + }, + "metrics": { + "description": "Measure definitions", + "items": { + "$ref": "#/$defs/Metric" + }, + "title": "Metrics", + "type": "array" + }, + "segments": { + "description": "Segment (named filter) definitions", + "items": { + "$ref": "#/$defs/Segment" + }, + "title": "Segments", + "type": "array" + }, + "pre_aggregations": { + "description": "Pre-aggregation definitions for query optimization", + "items": { + "$ref": "#/$defs/PreAggregation" + }, + "title": "Pre Aggregations", + "type": "array" + }, + "default_time_dimension": { + "anyOf": [ + { "type": "string" }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Default time dimension for metrics (auto-included in queries)", + "title": "Default Time Dimension" + }, + "default_grain": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" + "type": "string" }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" + { + "type": "null" + } + ], + "default": null, + "description": "Default time granularity when using default_time_dimension", + "title": "Default Grain" + }, + "auto_dimensions": { + "default": false, + "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", + "title": "Auto Dimensions", + "type": "boolean" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + } + }, + "required": [ + "name" + ], + "title": "Model", + "type": "object" + } + }, + "metrics": { + "type": "array", + "description": "Top-level metric definitions (optional - can also define in models)", + "items": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "extends": { + "anyOf": [ + { + "type": "string" }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" + "type": "string" }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "sql": { + "anyOf": [ + { + "type": "string" }, - "relationship_overrides": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/RelationshipOverride" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Relationship overrides for this metric", - "title": "Relationship Overrides" + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" }, - "required_models": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" ], - "default": null, - "description": "Additional models required for this metric", - "title": "Required Models" + "type": "string" }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" + "type": "string" }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "numerator": { + "anyOf": [ + { + "type": "string" }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "denominator": { + "anyOf": [ + { + "type": "string" }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "window": { + "anyOf": [ + { + "type": "string" }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" + "type": "string" }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "window_expression": { + "anyOf": [ + { + "type": "string" }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" + }, + "window_frame": { + "anyOf": [ + { + "type": "string" }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" + }, + "window_order": { + "anyOf": [ + { + "type": "string" }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" + { + "type": "null" } - }, - "required": [ - "name" ], - "title": "Metric", - "type": "object" + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" }, - "PreAggregation": { - "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", - "properties": { - "build_range_end": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for end of data range to aggregate", - "title": "Build Range End" + "base_metric": { + "anyOf": [ + { + "type": "string" }, - "build_range_start": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" ], - "default": null, - "description": "SQL expression for start of data range to aggregate", - "title": "Build Range Start" + "type": "string" }, - "dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to group by (e.g., ['status', 'region'])", - "title": "Dimensions" + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" }, - "granularity": { - "anyOf": [ - { - "enum": [ - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio" ], - "default": null, - "description": "Time granularity for aggregation", - "title": "Granularity" + "type": "string" }, - "indexes": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Index" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Index definitions for query performance", - "title": "Indexes" + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "entity": { + "anyOf": [ + { + "type": "string" }, - "measures": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", - "title": "Measures" + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "base_event": { + "anyOf": [ + { + "type": "string" }, - "name": { - "description": "Unique pre-aggregation name", - "title": "Name", + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "conversion_event": { + "anyOf": [ + { "type": "string" }, - "partition_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Partition size for incremental refresh", - "title": "Partition Granularity" + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" }, - "refresh_key": { - "anyOf": [ - { - "$ref": "#/$defs/RefreshKey" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh strategy configuration" + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "steps": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "scheduled_refresh": { - "default": true, - "description": "Whether to enable scheduled refresh", - "title": "Scheduled Refresh", - "type": "boolean" + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" + }, + "cohort_event": { + "anyOf": [ + { + "type": "string" }, - "time_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time dimension for temporal grouping", - "title": "Time Dimension" + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "activity_event": { + "anyOf": [ + { + "type": "string" }, - "type": { - "default": "rollup", - "description": "Pre-aggregation type", + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "retention_granularity": { + "anyOf": [ + { "enum": [ - "rollup", - "original_sql", - "rollup_join", - "lambda" + "day", + "week", + "month" ], - "title": "Type", "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" + }, + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" + }, + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + } + }, + "parameters": { + "type": "array", + "description": "Parameter definitions for dynamic queries", + "items": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + } + }, + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" + } + } + }, + "required": [ + "models" + ], + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "name": { + "description": "Unique dimension name within model", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Dimension type", + "enum": [ + "categorical", + "time", + "boolean", + "numeric" + ], + "title": "Type", + "type": "string" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" + }, + "supported_granularities": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" + }, + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name", + "type" + ], + "title": "Dimension", + "type": "object" + }, + "Metric": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" }, - "required": [ - "name" - ], - "title": "PreAggregation", - "type": "object" - }, - "RefreshKey": { - "description": "Refresh strategy configuration for pre-aggregations.", - "properties": { - "every": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", - "title": "Every" - }, - "incremental": { - "default": false, - "description": "Whether to use incremental refresh (only update changed partitions)", - "title": "Incremental", - "type": "boolean" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL query that returns a value to trigger refresh when changed", - "title": "Sql" - }, - "update_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", - "title": "Update Window" - } + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "numerator": { + "anyOf": [ + { + "type": "string" }, - "title": "RefreshKey", - "type": "object" - }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "active": { - "default": true, - "description": "Whether the relationship is active by default", - "title": "Active", - "type": "boolean" - }, - "foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "name": { - "description": "Name of the related model", - "title": "Name", - "type": "string" - }, - "primary_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "type": { - "description": "Type of relationship", - "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" - ], - "title": "Type", - "type": "string" - } + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" - }, - "RelationshipOverride": { - "description": "Overrides join behavior for a relationship in a metric/query context.", - "properties": { - "direction": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional filter direction hint", - "title": "Direction" - }, - "from_column": { - "description": "Source model column", - "title": "From Column", - "type": "string" - }, - "from_model": { - "description": "Source model name", - "title": "From Model", - "type": "string" - }, - "join_type": { - "anyOf": [ - { - "enum": [ - "inner", - "left", - "right", - "full" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional join type override", - "title": "Join Type" - }, - "to_column": { - "description": "Target model column", - "title": "To Column", - "type": "string" - }, - "to_model": { - "description": "Target model name", - "title": "To Model", - "type": "string" - } + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "window_expression": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "from_model", - "from_column", - "to_model", - "to_column" - ], - "title": "RelationshipOverride", - "type": "object" - }, - "Segment": { - "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", - "properties": { - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "name": { - "description": "Unique segment name", - "title": "Name", - "type": "string" - }, - "public": { - "default": true, - "description": "Whether segment is visible in API/UI", - "title": "Public", - "type": "boolean" - }, - "sql": { - "description": "SQL WHERE clause expression", - "title": "Sql", - "type": "string" - } + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" + }, + "window_frame": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "name", - "sql" - ], - "title": "Segment", - "type": "object" - } + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" }, - "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", - "properties": { - "auto_dimensions": { - "default": false, - "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", - "title": "Auto Dimensions", - "type": "boolean" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX table expression source text to lower into SQL", - "title": "Dax" - }, - "default_grain": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default time granularity when using default_time_dimension", - "title": "Default Grain" - }, - "default_time_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default time dimension for metrics (auto-included in queries)", - "title": "Default Time Dimension" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "dimensions": { - "description": "Dimension definitions", - "items": { - "$ref": "#/$defs/Dimension" + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio" + ], + "type": "string" }, - "title": "Dimensions", - "type": "array" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/dax derived table authoring", - "title": "Expression Language" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent model to inherit from", - "title": "Extends" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "metrics": { - "description": "Measure definitions", - "items": { - "$ref": "#/$defs/Metric" + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "entity": { + "anyOf": [ + { + "type": "string" }, - "title": "Metrics", - "type": "array" - }, - "name": { - "description": "Unique model name", - "title": "Name", - "type": "string" - }, - "pre_aggregations": { - "description": "Pre-aggregation definitions for query optimization", - "items": { - "$ref": "#/$defs/PreAggregation" + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "base_event": { + "anyOf": [ + { + "type": "string" }, - "title": "Pre Aggregations", - "type": "array" - }, - "primary_key": { - "anyOf": [ - { + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "steps": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "id", - "description": "Primary key column(s)", - "title": "Primary Key" - }, - "relationships": { - "description": "Relationships to other models", - "items": { - "$ref": "#/$defs/Relationship" + "type": "array" }, - "title": "Relationships", - "type": "array" - }, - "segments": { - "description": "Segment (named filter) definitions", - "items": { - "$ref": "#/$defs/Segment" + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" + }, + "cohort_event": { + "anyOf": [ + { + "type": "string" }, - "title": "Segments", - "type": "array" - }, - "source_uri": { - "anyOf": [ - { + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Remote data source URI (e.g., https://, s3://, gs://)", - "title": "Source Uri" - }, - "sql": { - "anyOf": [ - { + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" + }, + "filters": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for derived tables", - "title": "Sql" - }, - "table": { - "anyOf": [ - { + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "drill_fields": { + "anyOf": [ + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Physical table name (schema.table)", - "title": "Table" - }, - "unique_keys": { - "anyOf": [ - { - "items": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Unique key constraints (each is a list of columns)", - "title": "Unique Keys" - } + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" }, - "required": [ - "name" - ], - "title": "Model", - "type": "object" - }, - "type": "array" - }, - "parameters": { - "description": "Parameter definitions for dynamic queries", - "items": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" - }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "description": { - "anyOf": [ - { + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", + "properties": { + "name": { + "description": "Name of the related model", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Type of relationship", + "enum": [ + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" + ], + "title": "Type", + "type": "string" + }, + "foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - } + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" + "through": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Junction model for many_to_many relationships", + "title": "Through" + }, + "through_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" + }, + "related_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" + }, + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + } + }, + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "Parameter": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + } }, - "type": "array" + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" } - }, - "required": [ - "models" - ], - "title": "Sidemantic Semantic Layer", - "type": "object" + } } \ No newline at end of file diff --git a/sidemantic/adapters/sidemantic.py b/sidemantic/adapters/sidemantic.py index 13b280bb..48595371 100644 --- a/sidemantic/adapters/sidemantic.py +++ b/sidemantic/adapters/sidemantic.py @@ -11,7 +11,7 @@ from sidemantic.core.metric import Metric from sidemantic.core.model import Model from sidemantic.core.parameter import Parameter -from sidemantic.core.relationship import Relationship, RelationshipOverride +from sidemantic.core.relationship import Relationship from sidemantic.core.segment import Segment from sidemantic.core.semantic_graph import SemanticGraph from sidemantic.core.sql_definitions import ( @@ -95,9 +95,6 @@ class SidemanticAdapter(BaseAdapter): ``` """ - def __init__(self, lower_dax: bool = True): - self.lower_dax = lower_dax - def parse(self, source: str | Path) -> SemanticGraph: """Parse Sidemantic YAML or SQL into semantic graph. @@ -160,7 +157,6 @@ def parse(self, source: str | Path) -> SemanticGraph: graph.add_parameter(param) # Segments need to be attached to models, skip if no model - self._lower_dax_if_enabled(graph) return graph # Handle YAML files @@ -203,16 +199,8 @@ def parse(self, source: str | Path) -> SemanticGraph: # Note: segments need to be attached to models # For now, skip graph-level segments - self._lower_dax_if_enabled(graph) return graph - def _lower_dax_if_enabled(self, graph: SemanticGraph) -> None: - if not self.lower_dax: - return - from sidemantic.dax.modeling import lower_dax_graph_expressions - - lower_dax_graph_expressions(graph) - def export(self, graph: SemanticGraph, output_path: str | Path) -> None: """Export semantic graph to Sidemantic YAML. @@ -306,8 +294,6 @@ def _parse_model(self, model_def: dict) -> Model | None: drill_fields=measure_def.get("drill_fields"), non_additive_dimension=measure_def.get("non_additive_dimension"), metadata=measure_def.get("metadata"), - relationship_overrides=_parse_relationship_overrides(measure_def.get("relationship_overrides")), - required_models=measure_def.get("required_models"), base_metric=measure_def.get("base_metric"), comparison_type=measure_def.get("comparison_type"), time_offset=measure_def.get("time_offset"), @@ -473,8 +459,6 @@ def _parse_metric(self, metric_def: dict) -> Metric | None: value_format_name=metric_def.get("value_format_name"), drill_fields=metric_def.get("drill_fields"), non_additive_dimension=metric_def.get("non_additive_dimension"), - relationship_overrides=_parse_relationship_overrides(metric_def.get("relationship_overrides")), - required_models=metric_def.get("required_models"), ) def _parse_parameter(self, parameter_def: dict) -> Parameter | None: @@ -621,12 +605,6 @@ def _export_model(self, model: Model) -> dict: measure_def["drill_fields"] = measure.drill_fields if measure.non_additive_dimension: measure_def["non_additive_dimension"] = measure.non_additive_dimension - if measure.relationship_overrides: - measure_def["relationship_overrides"] = [ - _relationship_override_dict(override) for override in measure.relationship_overrides - ] - if measure.required_models: - measure_def["required_models"] = measure.required_models if measure.type: measure_def["type"] = measure.type if measure.base_metric: @@ -787,28 +765,12 @@ def _export_metric(self, measure: Metric, graph) -> dict: result["window"] = measure.window if measure.filters: result["filters"] = measure.filters - if measure.relationship_overrides: - result["relationship_overrides"] = [ - _relationship_override_dict(override) for override in measure.relationship_overrides - ] - if measure.required_models: - result["required_models"] = measure.required_models if not measure.public: result["public"] = measure.public return result -def _parse_relationship_overrides(value) -> list[RelationshipOverride] | None: - if not value: - return None - return [item if isinstance(item, RelationshipOverride) else RelationshipOverride(**item) for item in value] - - -def _relationship_override_dict(override: RelationshipOverride) -> dict: - return override.model_dump(exclude_none=True) - - def _dax_text(obj) -> str | None: dax = getattr(obj, "dax", None) if isinstance(dax, str) and dax.strip(): diff --git a/sidemantic/adapters/tmdl.py b/sidemantic/adapters/tmdl.py index 1980f061..a392faf5 100644 --- a/sidemantic/adapters/tmdl.py +++ b/sidemantic/adapters/tmdl.py @@ -12,13 +12,6 @@ from sidemantic.core.model import Model from sidemantic.core.relationship import Relationship from sidemantic.core.semantic_graph import SemanticGraph -from sidemantic.dax import ( - DaxTranslationError, - RelationshipEdge, - translate_dax_metric, - translate_dax_scalar, - translate_dax_table, -) if TYPE_CHECKING: from sidemantic_dax.ast import Expr as DaxExpr @@ -54,8 +47,6 @@ def parse(self, source: str | Path) -> SemanticGraph: table_nodes = [ node for node in _find_nodes(merged_nodes, {"table", "calculatedtable"}) if not _is_ref_only(node) ] - table_names = {node.name for node in table_nodes if node.name} - relationship_edges = _collect_relationship_edges(relationship_nodes, known_tables=table_names) column_sql_by_table, measure_names_by_table, measure_aggs_by_table, time_dimensions_by_table = ( _collect_table_metadata(table_nodes) ) @@ -68,7 +59,6 @@ def parse(self, source: str | Path) -> SemanticGraph: measure_names_by_table, measure_aggs_by_table, time_dimensions_by_table, - relationship_edges, warnings, ) model._source_format = "TMDL" @@ -330,7 +320,6 @@ def _table_to_model( measure_names_by_table: dict[str, set[str]], measure_aggs_by_table: dict[str, dict[str, str]], time_dimensions_by_table: dict[str, set[str]], - relationship_edges: list[RelationshipEdge], warnings: list[TmdlImportWarning], ) -> Model: props = _props(node) @@ -365,7 +354,6 @@ def _table_to_model( measure_names_by_table, measure_aggs_by_table, time_dimensions_by_table, - relationship_edges, warnings, ) if parsed_metrics: @@ -375,11 +363,15 @@ def _table_to_model( model_sql = None model_table = node.name or None - model_required_models: list[str] | None = None + model_dax = None + model_expression_language = None if node.type.lower() == "calculatedtable": + model_table = None expression_obj = _resolve_expression_object(node, props) expr_text = expression_obj.text if expression_obj else None original_expression = expr_text + model_dax = expr_text + model_expression_language = "dax" if expr_text else None if expr_text: try: dax_expr = _parse_dax_expression(expr_text, node, "table") @@ -404,40 +396,14 @@ def _table_to_model( ) dax_expr = None if dax_expr is not None: - try: - translation = translate_dax_table( - dax_expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - relationship_edges=relationship_edges, - ) - model_sql = translation.sql - model_required_models = sorted(translation.required_models) - _append_dax_translation_warnings( - warnings, - node, - context="calculated_table", - model_name=node.name, - translation_warnings=translation.warnings, - ) - except DaxTranslationError as exc: - _append_import_warning( - warnings, - node, - code="dax_translation_fallback", - context="calculated_table", - message=str(exc), - model_name=node.name, - ) - model_sql = None - if model_sql: - model_table = None + model_sql = None model = Model( name=node.name or "", table=model_table, sql=model_sql, + dax=model_dax, + expression_language=model_expression_language, description=description, primary_key=primary_key or "id", dimensions=dimensions, @@ -460,15 +426,11 @@ def _table_to_model( if original_expression: model._tmdl_expression = original_expression model.dax = original_expression - model._dax_skip_native_lowering = True - model._dax_lowered = model_sql is not None expression_obj = _resolve_expression_object(node, props) if expression_obj is not None: model._tmdl_expression_obj = _clone_tmdl_value(expression_obj) if "dax_expr" in locals() and dax_expr is not None: model._dax_ast = dax_expr - if model_required_models: - model._dax_required_models = model_required_models table_props = _node_passthrough_properties(node, {"description", "expression"}) if table_props: model._tmdl_properties = table_props @@ -524,27 +486,8 @@ def _column_to_dimension( dax_expr = None else: dax_expr = None - dax_lowered = False - if dax_expr is not None and expression: - try: - sql = translate_dax_scalar( - dax_expr, - model_name=table_name, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - time_dimensions_by_table=time_dimensions_by_table, - ) - dax_lowered = True - except DaxTranslationError as exc: - _append_import_warning( - warnings, - node, - code="dax_translation_fallback", - context="column", - message=str(exc), - model_name=table_name, - ) - sql = source_column or expression + if expression and not sql: + sql = source_column or expression if not sql: sql = node.name or "" @@ -560,8 +503,7 @@ def _column_to_dimension( public=not _is_true(props.get("ishidden")), ) if expression: - dimension._dax_skip_native_lowering = True - dimension._dax_lowered = dax_lowered + dimension.expression_language = "dax" dimension._source_format = "TMDL" if node.location and node.location.file: try: @@ -607,7 +549,6 @@ def _measure_to_metric( measure_names_by_table: dict[str, set[str]], measure_aggs_by_table: dict[str, dict[str, str]], time_dimensions_by_table: dict[str, set[str]], - relationship_edges: list[RelationshipEdge], warnings: list[TmdlImportWarning], ) -> list[Metric]: props = _props(node) @@ -639,108 +580,6 @@ def _measure_to_metric( model_name=table_name, ) dax_expr = None - translation = None - if dax_expr is not None: - try: - translation = translate_dax_metric( - dax_expr, - model_name=table_name, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table=measure_aggs_by_table, - time_dimensions_by_table=time_dimensions_by_table, - relationship_edges=relationship_edges, - ) - except DaxTranslationError as exc: - _append_import_warning( - warnings, - node, - code="dax_translation_fallback", - context="measure", - message=str(exc), - model_name=table_name, - ) - translation = None - - if translation: - metric_type = translation.type - if metric_type is None and translation.sql and not translation.agg: - metric_type = "derived" - inline_metrics: list[Metric] = [] - base_metric_ref = translation.base_metric - if ( - metric_type == "time_comparison" - and not base_metric_ref - and translation.inline_base_agg - and translation.inline_base_sql - ): - base_metric_name = _inline_base_metric_name( - node.name or "metric", measure_names_by_table.get(table_name, set()) - ) - base_metric_ref = f"{table_name}.{base_metric_name}" - inline_metrics.append( - Metric( - name=base_metric_name, - agg=translation.inline_base_agg, - sql=translation.inline_base_sql, - filters=translation.inline_base_filters or None, - ) - ) - - metric = Metric( - name=node.name or "", - agg=translation.agg, - sql=translation.sql, - dax=expression, - type=metric_type, - base_metric=base_metric_ref, - comparison_type=translation.comparison_type, - calculation=translation.calculation, - time_offset=translation.time_offset, - window=translation.window, - grain_to_date=translation.grain_to_date, - window_order=translation.window_order, - filters=translation.filters or None, - relationship_overrides=translation.relationship_overrides or None, - required_models=sorted(translation.required_models) if translation.required_models else None, - description=node.description or _string_prop(_props(node).get("description")), - label=_string_prop(_props(node).get("caption")), - format=_string_prop(_props(node).get("formatstring")), - public=not _is_true(props.get("ishidden")), - ) - metric._dax_lowered = True - metric._dax_skip_native_lowering = True - metric._source_format = "TMDL" - if node.location and node.location.file: - try: - metric._source_file = str(Path(node.location.file).relative_to(root)) - except ValueError: - metric._source_file = node.location.file - if dax_expr is not None: - metric._dax_ast = dax_expr - if node.name_raw: - metric._tmdl_name_raw = node.name_raw - if node.leading_comments: - metric._tmdl_leading_comments = list(node.leading_comments) - metric._tmdl_expression = expression - if expression_obj is not None: - metric._tmdl_expression_obj = _clone_tmdl_value(expression_obj) - measure_props = _node_passthrough_properties( - node, {"caption", "formatstring", "description", "expression", "ishidden"} - ) - if measure_props: - metric._tmdl_properties = measure_props - raw_value_props = _node_raw_value_properties(node) - if raw_value_props: - metric._tmdl_raw_value_properties = raw_value_props - property_order = [prop.name.lower() for prop in node.properties if isinstance(prop.name, str)] - if property_order: - metric._tmdl_property_order = property_order - if node.children: - metric._tmdl_child_nodes = [_clone_tmdl_node(child) for child in node.children] - inline_metrics.append(metric) - return inline_metrics - agg, sql = _extract_dax_agg(expression, table_name, dax_expr) metric_type = None if agg else "derived" metric = Metric( @@ -755,8 +594,7 @@ def _measure_to_metric( public=not _is_true(props.get("ishidden")), ) if expression: - metric._dax_skip_native_lowering = True - metric._dax_lowered = False + metric.expression_language = "dax" metric._source_format = "TMDL" if node.location and node.location.file: try: @@ -788,23 +626,6 @@ def _measure_to_metric( return [metric] -def _inline_base_metric_name(metric_name: str, existing_names: set[str]) -> str: - stem_chars: list[str] = [] - for ch in metric_name: - if ch.isalnum(): - stem_chars.append(ch.lower()) - else: - stem_chars.append("_") - stem = "".join(stem_chars).strip("_") or "metric" - candidate = f"__{stem}_base" - if candidate not in existing_names: - return candidate - idx = 2 - while f"{candidate}_{idx}" in existing_names: - idx += 1 - return f"{candidate}_{idx}" - - def _apply_relationships( graph: SemanticGraph, nodes: list[TmdlNode], root: Path, warnings: list[TmdlImportWarning] | None = None ) -> None: @@ -986,38 +807,6 @@ def _clone_tmdl_node(node: TmdlNode) -> TmdlNode: ) -def _collect_relationship_edges( - nodes: list[TmdlNode], *, known_tables: set[str] | None = None -) -> list[RelationshipEdge]: - edges: list[RelationshipEdge] = [] - for node in nodes: - props = _props(node) - if _is_false(props.get("isactive")): - continue - from_ref = _string_prop(props.get("fromcolumn")) - to_ref = _string_prop(props.get("tocolumn")) - from_table, from_column = _parse_column_reference(from_ref) - to_table, to_column = _parse_column_reference(to_ref) - if not from_table or not from_column or not to_table or not to_column: - continue - if known_tables is not None and (from_table not in known_tables or to_table not in known_tables): - continue - if not _map_relationship_type( - _string_prop(props.get("fromcardinality")), - _string_prop(props.get("tocardinality")), - ): - continue - edges.append( - RelationshipEdge( - from_table=from_table, - from_column=from_column, - to_table=to_table, - to_column=to_column, - ) - ) - return edges - - def _find_default_time_dimension(dimensions: list[Dimension]) -> str | None: for dimension in dimensions: if dimension.type == "time": @@ -1165,27 +954,6 @@ def _append_import_warning( warnings.append(warning) -def _append_dax_translation_warnings( - warnings: list[TmdlImportWarning], - node: TmdlNode, - *, - context: str, - model_name: str | None, - translation_warnings: list[dict[str, Any]], -) -> None: - for translation_warning in translation_warnings: - if not isinstance(translation_warning, dict): - continue - _append_import_warning( - warnings, - node, - code=str(translation_warning.get("code") or "dax_translation_warning"), - context=context, - message=str(translation_warning.get("message") or "DAX translation warning"), - model_name=model_name, - ) - - def _append_export_warning( warnings: list[TmdlExportWarning], *, @@ -1536,7 +1304,7 @@ def _export_table(model: Model) -> str: model_name_raw = getattr(model, "_tmdl_name_raw", None) model_expression_obj = _dax_expression_for_export(model) is_calculated_table = node_type == "calculatedtable" or ( - model_expression_obj is not None and getattr(model, "expression_language", None) == "dax" + model_expression_obj is not None and (getattr(model, "expression_language", None) == "dax" or not model.table) ) if is_calculated_table and model_expression_obj and model_expression_obj.text.strip(): _append_expression_assignment( diff --git a/sidemantic/cli.py b/sidemantic/cli.py index 3807f4bb..f0a81948 100644 --- a/sidemantic/cli.py +++ b/sidemantic/cli.py @@ -1,7 +1,6 @@ """CLI for sidemantic semantic layer operations.""" from pathlib import Path -from typing import Any import typer @@ -25,86 +24,6 @@ def version_callback(value: bool): _loaded_config: SidemanticConfig | None = None -def _parse_dax_query(text: str) -> Any: - from sidemantic.dax.runtime import parse_dax_query - - return parse_dax_query(text) - - -def _translate_dax_query_ast(query_ast: Any, layer: SemanticLayer) -> Any: - from sidemantic.dax.runtime import translate_dax_query_ast - - return translate_dax_query_ast(query_ast, layer.graph) - - -def _get_import_warnings(layer: SemanticLayer) -> list[dict[str, Any]]: - warnings = getattr(layer.graph, "import_warnings", None) - if not isinstance(warnings, list): - return [] - out: list[dict[str, Any]] = [] - for warning in warnings: - if isinstance(warning, dict): - out.append(warning) - return out - - -def _emit_import_warnings(layer: SemanticLayer, limit: int = 8) -> None: - warnings = _get_import_warnings(layer) - if not warnings: - return - typer.echo(f"Warning: {len(warnings)} model import warning(s).", err=True) - for warning in warnings[:limit]: - code = warning.get("code", "warning") - context = warning.get("context", "model") - name = warning.get("name", "") - message = warning.get("message", "") - file = warning.get("file") - line = warning.get("line") - column = warning.get("column") - location = "" - if file and line and column: - location = f" ({file}:{line}:{column})" - elif file: - location = f" ({file})" - typer.echo(f" - [{code}] {context} {name}: {message}{location}", err=True) - if len(warnings) > limit: - typer.echo(f" - ... {len(warnings) - limit} more warning(s)", err=True) - - -def _emit_dax_query_warnings(warnings: Any, limit: int = 8) -> None: - if not isinstance(warnings, list) or not warnings: - return - structured = [warning for warning in warnings if isinstance(warning, dict)] - if not structured: - return - typer.echo(f"Warning: {len(structured)} DAX query warning(s).", err=True) - for warning in structured[:limit]: - code = warning.get("code", "warning") - context = warning.get("context", "query") - message = warning.get("message", "") - detail = ", ".join( - f"{key}={value}" - for key in ("base_table", "table", "model", "name") - if (value := warning.get(key)) is not None - ) - suffix = f" ({detail})" if detail else "" - typer.echo(f" - [{code}] {context}: {message}{suffix}", err=True) - if len(structured) > limit: - typer.echo(f" - ... {len(structured) - limit} more warning(s)", err=True) - - -def _load_models_path(layer: SemanticLayer, models_path: Path) -> None: - """Load a directory of model files or a single supported model file into an existing layer.""" - if models_path.is_dir(): - load_from_directory(layer, str(models_path)) - return - if models_path.is_file(): - loaded_layer = SemanticLayer.from_yaml(models_path, connection=layer.adapter) - layer.graph = loaded_layer.graph - return - raise ValueError(f"Model path {models_path} does not exist") - - @app.callback() def main( version: bool = typer.Option( @@ -464,7 +383,7 @@ def query( sidemantic query "SELECT revenue FROM orders" --dry-run """ if not models.exists(): - typer.echo(f"Error: Model path {models} does not exist", err=True) + typer.echo(f"Error: Directory {models} does not exist", err=True) raise typer.Exit(1) try: @@ -498,7 +417,7 @@ def query( ) else: layer = SemanticLayer(preagg_database=preagg_db, preagg_schema=preagg_sch) - _load_models_path(layer, models) + load_from_directory(layer, str(models)) if not layer.graph.models: typer.echo("Error: No models found", err=True) @@ -540,99 +459,6 @@ def query( raise typer.Exit(1) -@app.command("dax-query") -def dax_query( - dax: str = typer.Argument(..., help="DAX query to execute"), - models: Path = typer.Option(".", "--models", "-m", help="Directory containing semantic layer files"), - output: Path = typer.Option(None, "--output", "-o", help="Output file (default: stdout)"), - connection: str = typer.Option( - None, "--connection", help="Database connection string (e.g., postgres://host/db, bigquery://project/dataset)" - ), - db: Path = typer.Option(None, "--db", help="Path to DuckDB database file (shorthand for duckdb:/// connection)"), - dry_run: bool = typer.Option(False, "--dry-run", help="Show translated SQL without executing"), - evaluate: int = typer.Option(1, "--evaluate", help="1-based EVALUATE statement index to execute"), -): - """ - Execute a DAX query and output results as CSV. - - Examples: - sidemantic dax-query "EVALUATE VALUES('orders'[status])" - sidemantic dax-query "EVALUATE VALUES('orders'[status])" --db data.duckdb - sidemantic dax-query "EVALUATE VALUES('orders'[status])" --dry-run - sidemantic dax-query "EVALUATE ...; EVALUATE ..." --evaluate 2 - """ - if evaluate < 1: - typer.echo("Error: --evaluate must be >= 1", err=True) - raise typer.Exit(1) - - if not models.exists(): - typer.echo(f"Error: Model path {models} does not exist", err=True) - raise typer.Exit(1) - - try: - connection_str = None - init_sql = None - if connection: - connection_str = connection - elif db: - connection_str = f"duckdb:///{db.absolute()}" - elif _loaded_config and _loaded_config.connection: - connection_str = build_connection_string(_loaded_config) - init_sql = get_init_sql(_loaded_config) - else: - data_dir = models / "data" - if data_dir.exists(): - db_files = list(data_dir.glob("*.db")) - if db_files: - connection_str = f"duckdb:///{db_files[0].absolute()}" - - preagg_db = _loaded_config.preagg_database if _loaded_config else None - preagg_sch = _loaded_config.preagg_schema if _loaded_config else None - if connection_str: - layer = SemanticLayer( - connection=connection_str, - preagg_database=preagg_db, - preagg_schema=preagg_sch, - init_sql=init_sql, - ) - else: - layer = SemanticLayer(preagg_database=preagg_db, preagg_schema=preagg_sch) - _load_models_path(layer, models) - _emit_import_warnings(layer) - - if not layer.graph.models: - typer.echo("Error: No models found", err=True) - raise typer.Exit(1) - - dax_payload = layer.compile_dax_query_payload(dax, evaluate=evaluate) - translated_sql = str(dax_payload["sql"]) - _emit_dax_query_warnings(dax_payload.get("warnings")) - if dry_run: - typer.echo(translated_sql) - return - - result = layer.adapter.execute(translated_sql) - columns = [desc[0] for desc in result.description] - rows = result.fetchall() - - import csv - import sys - - if output: - with open(output, "w", newline="") as f: - writer = csv.writer(f) - writer.writerow(columns) - writer.writerows(rows) - typer.echo(f"Results written to {output}", err=True) - else: - writer = csv.writer(sys.stdout) - writer.writerow(columns) - writer.writerows(rows) - except Exception as e: - typer.echo(f"Error: {e}", err=True) - raise typer.Exit(1) - - @app.command() def serve( directory: Path = typer.Argument(".", help="Directory containing semantic layer files (defaults to current dir)"), diff --git a/sidemantic/core/dimension.py b/sidemantic/core/dimension.py index b3df5ccc..67584810 100644 --- a/sidemantic/core/dimension.py +++ b/sidemantic/core/dimension.py @@ -14,7 +14,7 @@ class Dimension(BaseModel): name: str = Field(..., description="Unique dimension name within model") type: Literal["categorical", "time", "boolean", "numeric"] = Field(..., description="Dimension type") sql: str | None = Field(None, description="SQL expression (defaults to name; accepts 'expr' as alias)") - dax: str | None = Field(None, description="DAX expression source text to lower into SQL") + dax: str | None = Field(None, description="DAX expression source text") expression_language: Literal["sql", "dax"] | None = Field( None, description="Expression language for sql/expr/dax authoring" ) diff --git a/sidemantic/core/introspection.py b/sidemantic/core/introspection.py index 37af24da..d478eaf1 100644 --- a/sidemantic/core/introspection.py +++ b/sidemantic/core/introspection.py @@ -36,10 +36,7 @@ def _include_graph_metric(metric: Metric, requested_models: set[str]) -> bool: owner_model = _metric_owner_model(metric) if owner_model and owner_model in requested_models: return True - required_models = getattr(metric, "required_models", None) or [] - if not required_models: - return True - return set(required_models).issubset(requested_models) + return owner_model is None def _metric_owner_model(metric: Metric) -> str | None: @@ -130,10 +127,6 @@ def _describe_metric( "window_order": metric.window_order, "filters": metric.filters or [], "drill_fields": metric.drill_fields or [], - "required_models": metric.required_models, - "relationship_overrides": [ - _relationship_override_info(override) for override in metric.relationship_overrides or [] - ], "public": metric.public, } _add_common_fields(info, metric, warnings, context="measure", model_name=model_name, inherited_from=model) @@ -209,12 +202,6 @@ def _add_common_fields( if tmdl_expression or dax_expression: info["original_expression"] = tmdl_expression or dax_expression - lowered = bool(getattr(obj, "_dax_lowered", False)) - if lowered: - info["dax_lowered"] = True - if required_models := getattr(obj, "_dax_required_models", None): - info["dax_required_models"] = required_models - tmdl_metadata = _tmdl_metadata(obj) if tmdl_metadata: info["tmdl"] = tmdl_metadata @@ -227,8 +214,6 @@ def _add_common_fields( model_name=model_name, alternate_names=alternate_warning_names, ) - if "import_warnings" not in info and (tmdl_expression or dax_expression): - info["faithful_lowering"] = True def _tmdl_metadata(obj: Any) -> dict[str, Any]: @@ -277,12 +262,9 @@ def _add_warning_fields( return info["import_warnings"] = matched info["unsupported"] = any( - warning.get("code") - in {"dax_parse_error", "dax_parser_unavailable", "dax_translation_fallback", "relationship_parse_skip"} + warning.get("code") in {"dax_parse_error", "dax_parser_unavailable", "relationship_parse_skip"} for warning in matched ) - if context in {"column", "measure", "calculated_table", "relationship"}: - info["faithful_lowering"] = not info["unsupported"] def _dax_expression_text(obj: Any) -> str | None: @@ -340,7 +322,3 @@ def _json_safe(value: Any) -> Any: def _drop_none(value: dict[str, Any]) -> dict[str, Any]: return {key: item for key, item in value.items() if item is not None and item != []} - - -def _relationship_override_info(override: Any) -> dict[str, Any]: - return _json_safe(override) diff --git a/sidemantic/core/metric.py b/sidemantic/core/metric.py index a69d1094..1bc58d71 100644 --- a/sidemantic/core/metric.py +++ b/sidemantic/core/metric.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field, model_validator from .dependency_analyzer import extract_metric_dependencies -from .relationship import RelationshipOverride class Metric(BaseModel): @@ -51,14 +50,10 @@ def __init__(self, **data): | None ) = Field(None, description="Aggregation function (for simple measures)") sql: str | None = Field(None, description="SQL expression or formula (accepts 'expr' as alias)") - dax: str | None = Field(None, description="DAX expression source text to lower into SQL") + dax: str | None = Field(None, description="DAX expression source text") expression_language: Literal["sql", "dax"] | None = Field( None, description="Expression language for sql/expr/dax authoring" ) - relationship_overrides: list[RelationshipOverride] | None = Field( - None, description="Relationship overrides for this metric" - ) - required_models: list[str] | None = Field(None, description="Additional models required for this metric") @model_validator(mode="before") @classmethod @@ -281,7 +276,7 @@ def validate_type_specific_fields(self): None, description="Type of time comparison" ) time_offset: str | None = Field(None, description="Custom time offset (e.g., '1 month')") - calculation: Literal["difference", "percent_change", "ratio", "previous_value"] | None = Field( + calculation: Literal["difference", "percent_change", "ratio"] | None = Field( None, description="Comparison calculation (default: percent_change)" ) diff --git a/sidemantic/core/model.py b/sidemantic/core/model.py index bcec9984..9e00f1b1 100644 --- a/sidemantic/core/model.py +++ b/sidemantic/core/model.py @@ -21,7 +21,7 @@ class Model(BaseModel): name: str = Field(..., description="Unique model name") table: str | None = Field(None, description="Physical table name (schema.table)") sql: str | None = Field(None, description="SQL expression for derived tables") - dax: str | None = Field(None, description="DAX table expression source text to lower into SQL") + dax: str | None = Field(None, description="DAX table expression source text") expression_language: Literal["sql", "dax"] | None = Field( None, description="Expression language for sql/dax derived table authoring" ) diff --git a/sidemantic/core/relationship.py b/sidemantic/core/relationship.py index 828c96ec..b5172122 100644 --- a/sidemantic/core/relationship.py +++ b/sidemantic/core/relationship.py @@ -84,16 +84,3 @@ def junction_keys(self) -> tuple[str | None, str | None]: if self.type != "many_to_many": return None, None return self.through_foreign_key or self.foreign_key, self.related_foreign_key - - -class RelationshipOverride(BaseModel): - """Overrides join behavior for a relationship in a metric/query context.""" - - from_model: str = Field(description="Source model name") - from_column: str = Field(description="Source model column") - to_model: str = Field(description="Target model name") - to_column: str = Field(description="Target model column") - join_type: Literal["inner", "left", "right", "full"] | None = Field( - default=None, description="Optional join type override" - ) - direction: str | None = Field(default=None, description="Optional filter direction hint") diff --git a/sidemantic/core/semantic_graph.py b/sidemantic/core/semantic_graph.py index c09b6edf..66a67274 100644 --- a/sidemantic/core/semantic_graph.py +++ b/sidemantic/core/semantic_graph.py @@ -6,7 +6,6 @@ from sidemantic.core.metric import Metric from sidemantic.core.model import Model from sidemantic.core.parameter import Parameter -from sidemantic.core.relationship import RelationshipOverride from sidemantic.core.table_calculation import TableCalculation @@ -26,7 +25,6 @@ class JoinPath: from_columns: list[str] # Foreign key column(s) in from_model to_columns: list[str] # Primary/unique key column(s) in to_model relationship: str # many_to_one, one_to_many, one_to_one - join_type_override: str | None = None # Backwards compatibility properties (return first column) @property @@ -53,16 +51,9 @@ def __init__(self): self.table_calculations: dict[str, TableCalculation] = {} self.parameters: dict[str, Parameter] = {} self.import_warnings: list[dict[str, object]] = [] - self._revision = 0 self._adjacency: dict[ - str, list[tuple[str, list[str], list[str], str, str | None]] - ] = {} # model -> [(to_model, from_keys, to_keys, rel_type, join_type_override)] - self._relationship_path_cache: dict[tuple[int, str, str], tuple[JoinPath, ...]] = {} - - def _mark_structure_dirty(self) -> None: - self._revision += 1 - self._adjacency_dirty = True - self._relationship_path_cache.clear() + str, list[tuple[str, list[str], list[str], str]] + ] = {} # model -> [(to_model, from_keys, to_keys, rel_type)] def add_model(self, model: Model) -> None: """Add a model to the graph. @@ -85,7 +76,7 @@ def add_model(self, model: Model) -> None: if metric.name not in self.metrics: self.metrics[metric.name] = metric - self._mark_structure_dirty() + self._adjacency_dirty = True def add_metric(self, measure: Metric) -> None: """Add a measure to the graph. @@ -97,7 +88,6 @@ def add_metric(self, measure: Metric) -> None: raise ValueError(f"Measure {measure.name} already exists") self.metrics[measure.name] = measure - self._revision += 1 def add_table_calculation(self, calc: TableCalculation) -> None: """Add a table calculation to the graph. @@ -109,7 +99,6 @@ def add_table_calculation(self, calc: TableCalculation) -> None: raise ValueError(f"Table calculation {calc.name} already exists") self.table_calculations[calc.name] = calc - self._revision += 1 def get_table_calculation(self, name: str) -> TableCalculation: """Get a table calculation by name. @@ -141,7 +130,6 @@ def add_parameter(self, param: Parameter) -> None: raise ValueError(f"Parameter {param.name} already exists") self.parameters[param.name] = param - self._revision += 1 def get_parameter(self, name: str) -> Parameter: """Get a parameter by name. @@ -214,19 +202,13 @@ def build_adjacency(self) -> None: if not hasattr(self, "_adjacency"): self._adjacency = {} self._adjacency.clear() - self._relationship_path_cache.clear() def add_edge( - from_model: str, - to_model: str, - from_keys: list[str], - to_keys: list[str], - relationship_type: str, - join_type_override: str | None = None, + from_model: str, to_model: str, from_keys: list[str], to_keys: list[str], relationship_type: str ) -> None: if from_model not in self._adjacency: self._adjacency[from_model] = [] - self._adjacency[from_model].append((to_model, from_keys, to_keys, relationship_type, join_type_override)) + self._adjacency[from_model].append((to_model, from_keys, to_keys, relationship_type)) def invert_relationship(relationship_type: str) -> str: if relationship_type == "many_to_one": @@ -240,6 +222,7 @@ def invert_relationship(relationship_type: str) -> str: for relationship in model.relationships: if not relationship.active: continue + related_model = relationship.name if related_model not in self.models: continue # Skip if related model doesn't exist yet @@ -292,20 +275,12 @@ def invert_relationship(relationship_type: str) -> str: add_edge(model_name, related_model, local_keys, remote_keys, relationship.type) add_edge(related_model, model_name, remote_keys, local_keys, invert_relationship(relationship.type)) - def find_relationship_path( - self, - from_model: str, - to_model: str, - relationship_overrides: list[RelationshipOverride] | None = None, - ) -> list[JoinPath]: + def find_relationship_path(self, from_model: str, to_model: str) -> list[JoinPath]: """Find join path between two models using BFS. Args: from_model: Source model name to_model: Target model name - relationship_overrides: Query-local relationship edges that take - precedence over active graph relationships between the same - model pair. Returns: List of JoinPath objects representing the join sequence @@ -325,16 +300,6 @@ def find_relationship_path( if to_model not in self.models: raise KeyError(f"Model {to_model} not found") - adjacency = self._adjacency - if relationship_overrides: - adjacency = self._adjacency_with_relationship_overrides(relationship_overrides) - - cache_key = (self._revision, from_model, to_model) - if not relationship_overrides: - cached = self._relationship_path_cache.get(cache_key) - if cached is not None: - return list(cached) - # BFS to find shortest path queue = deque([(from_model, [])]) visited = {from_model} @@ -342,10 +307,10 @@ def find_relationship_path( while queue: current, path = queue.popleft() - if current not in adjacency: + if current not in self._adjacency: continue - for next_model, from_keys, to_keys, relationship_type, join_type_override in adjacency[current]: + for next_model, from_keys, to_keys, relationship_type in self._adjacency[current]: if next_model in visited: continue @@ -358,56 +323,16 @@ def find_relationship_path( from_columns=from_keys, to_columns=to_keys, relationship=relationship_type, - join_type_override=join_type_override, ) ] if next_model == to_model: - if not relationship_overrides: - self._relationship_path_cache[cache_key] = tuple(new_path) return new_path queue.append((next_model, new_path)) raise ValueError(f"No join path found between {from_model} and {to_model}") - def _adjacency_with_relationship_overrides( - self, relationship_overrides: list[RelationshipOverride] - ) -> dict[str, list[tuple[str, list[str], list[str], str, str | None]]]: - adjacency = {model: list(edges) for model, edges in self._adjacency.items()} - - for override in relationship_overrides: - if override.from_model not in self.models or override.to_model not in self.models: - continue - - from_model = override.from_model - to_model = override.to_model - pair = frozenset((from_model, to_model)) - - for model_name, edges in list(adjacency.items()): - adjacency[model_name] = [edge for edge in edges if frozenset((model_name, edge[0])) != pair] - - adjacency.setdefault(from_model, []).append( - ( - to_model, - [override.from_column], - [override.to_column], - "many_to_one", - override.join_type, - ) - ) - adjacency.setdefault(to_model, []).append( - ( - from_model, - [override.to_column], - [override.from_column], - "one_to_many", - override.join_type, - ) - ) - - return adjacency - def find_all_models_for_query(self, dimensions: list[str], measures: list[str]) -> set[str]: """Find all models needed for a query. diff --git a/sidemantic/core/semantic_layer.py b/sidemantic/core/semantic_layer.py index af8aad99..c2de808b 100644 --- a/sidemantic/core/semantic_layer.py +++ b/sidemantic/core/semantic_layer.py @@ -209,8 +209,6 @@ def add_model(self, model: Model) -> None: if model.auto_dimensions: self._introspect_dimensions(model) - self._lower_model_dax(model) - errors = validate_model(model) if errors: raise ModelValidationError( @@ -219,16 +217,6 @@ def add_model(self, model: Model) -> None: self.graph.add_model(model) - def _lower_model_dax(self, model: Model) -> None: - from sidemantic.core.semantic_graph import SemanticGraph - from sidemantic.dax.modeling import lower_dax_model_expressions - - graph = SemanticGraph() - graph.models.update(self.graph.models) - graph.metrics.update(self.graph.metrics) - graph.models[model.name] = model - lower_dax_model_expressions(model, graph) - def _normalize_model_table(self, model: Model) -> None: """Normalize model.table for the active dialect when needed.""" if not model.table or model.sql: @@ -437,8 +425,6 @@ def add_metric(self, measure: Metric) -> None: if existing.model_dump() == measure.model_dump(): return - self._lower_graph_metric_dax(measure) - errors = validate_metric(measure, self.graph) if errors: raise MetricValidationError( @@ -447,16 +433,6 @@ def add_metric(self, measure: Metric) -> None: self.graph.add_metric(measure) - def _lower_graph_metric_dax(self, measure: Metric) -> None: - from sidemantic.core.semantic_graph import SemanticGraph - from sidemantic.dax.modeling import lower_dax_graph_expressions - - graph = SemanticGraph() - graph.models.update(self.graph.models) - graph.metrics.update(self.graph.metrics) - graph.metrics[measure.name] = measure - lower_dax_graph_expressions(graph) - def query( self, metrics: list[str] | None = None, @@ -504,103 +480,6 @@ def query( return self.adapter.execute(sql) - def translate_dax_query(self, dax: str): - """Parse and translate a DAX query against this semantic graph. - - Returns a ``QueryTranslation`` with one SQL payload per DAX EVALUATE - statement. This is the reusable API behind CLI/MCP/Sidequery DAX query - execution. - """ - from sidemantic.dax.runtime import parse_dax_query, translate_dax_query_ast - - return translate_dax_query_ast(parse_dax_query(dax), self.graph) - - def compile_dax_query(self, dax: str, evaluate: int = 1) -> str: - """Compile one DAX EVALUATE statement to SQL without executing it.""" - sql, _warnings = self._compile_dax_query_with_warnings(dax, evaluate=evaluate) - return sql - - def compile_dax_query_payload(self, dax: str, evaluate: int = 1) -> dict[str, object]: - """Compile one DAX EVALUATE statement to JSON-friendly SQL metadata.""" - sql, warnings = self._compile_dax_query_with_warnings(dax, evaluate=evaluate) - return { - "sql": sql, - "warnings": warnings, - "import_warnings": self.get_import_warnings(), - } - - def _compile_dax_query_with_warnings(self, dax: str, evaluate: int = 1) -> tuple[str, list[dict[str, object]]]: - """Compile one DAX EVALUATE and collect query-level warnings.""" - if evaluate < 1: - raise ValueError("evaluate must be >= 1") - - translation = self.translate_dax_query(dax) - if not translation.evaluates: - raise ValueError("DAX query contains no EVALUATE statements") - if evaluate > len(translation.evaluates): - raise ValueError( - f"evaluate index {evaluate} is out of range; query has {len(translation.evaluates)} EVALUATE statement(s)" - ) - evaluate_translation = translation.evaluates[evaluate - 1] - warnings = [ - *self._normalize_dax_warnings(getattr(translation, "warnings", [])), - *self._normalize_dax_warnings(getattr(evaluate_translation, "warnings", [])), - ] - return evaluate_translation.sql, warnings - - def query_dax(self, dax: str, evaluate: int = 1): - """Execute one DAX EVALUATE statement against the semantic layer.""" - return self.adapter.execute(self.compile_dax_query(dax, evaluate=evaluate)) - - def run_dax_query(self, dax: str, evaluate: int = 1, dry_run: bool = False) -> dict[str, object]: - """Compile and optionally execute one DAX EVALUATE statement. - - Returns a JSON-friendly payload for UI, MCP, and FFI callers. - """ - sql, warnings = self._compile_dax_query_with_warnings(dax, evaluate=evaluate) - import_warnings = self.get_import_warnings() - if dry_run: - return { - "sql": sql, - "rows": [], - "row_count": 0, - "warnings": warnings, - "import_warnings": import_warnings, - } - - result = self.adapter.execute(sql) - rows = result.fetchall() - columns = [desc[0] for desc in result.description] - row_dicts = [ - {column: self._result_value_to_json_compatible(value) for column, value in zip(columns, row)} - for row in rows - ] - - return { - "sql": sql, - "rows": row_dicts, - "row_count": len(row_dicts), - "warnings": warnings, - "import_warnings": import_warnings, - } - - @staticmethod - def _normalize_dax_warnings(warnings: object) -> list[dict[str, object]]: - if not isinstance(warnings, list): - return [] - return [dict(warning) for warning in warnings if isinstance(warning, dict)] - - @staticmethod - def _result_value_to_json_compatible(value): - from datetime import date, datetime, time - from decimal import Decimal - - if isinstance(value, Decimal): - return float(value) - if isinstance(value, (date, datetime, time)): - return value.isoformat() - return value - def get_import_warnings(self) -> list[dict[str, object]]: """Return structured warnings produced while importing model definitions.""" return list(getattr(self.graph, "import_warnings", []) or []) diff --git a/sidemantic/dax/__init__.py b/sidemantic/dax/__init__.py deleted file mode 100644 index 21f763c5..00000000 --- a/sidemantic/dax/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -"""DAX translation helpers.""" - -from sidemantic.dax.modeling import DaxModelingError, lower_dax_graph_expressions, lower_dax_model_expressions -from sidemantic.dax.translator import ( - DaxTranslationError, - MetricTranslation, - QueryEvaluateTranslation, - QueryTranslation, - RelationshipEdge, - RelationshipOverride, - TableTranslation, - translate_dax_metric, - translate_dax_query, - translate_dax_scalar, - translate_dax_table, -) - -__all__ = [ - "DaxTranslationError", - "DaxModelingError", - "MetricTranslation", - "QueryEvaluateTranslation", - "QueryTranslation", - "RelationshipEdge", - "RelationshipOverride", - "TableTranslation", - "translate_dax_query", - "translate_dax_metric", - "translate_dax_scalar", - "translate_dax_table", - "lower_dax_graph_expressions", - "lower_dax_model_expressions", -] diff --git a/sidemantic/dax/modeling.py b/sidemantic/dax/modeling.py deleted file mode 100644 index 1bb0b753..00000000 --- a/sidemantic/dax/modeling.py +++ /dev/null @@ -1,335 +0,0 @@ -"""Lower DAX-authored model definitions into executable Sidemantic fields.""" - -from __future__ import annotations - -from typing import Any - -from sidemantic.core.metric import Metric -from sidemantic.core.model import Model -from sidemantic.core.semantic_graph import SemanticGraph -from sidemantic.dax.translator import ( - DaxTranslationError, - RelationshipEdge, - translate_dax_metric, - translate_dax_scalar, - translate_dax_table, -) - - -class DaxModelingError(ValueError): - """Raised when a DAX-authored model definition cannot be lowered.""" - - -def lower_dax_graph_expressions(graph: SemanticGraph) -> None: - """Lower first-class DAX expressions on graph models and graph metrics. - - TMDL import can keep warning/fallback behavior inside the TMDL adapter. Native - Sidemantic authoring is stricter: if a user explicitly marks an expression as - DAX, unsupported syntax should fail at model load time instead of becoming - accidental SQL. - """ - for model in graph.models.values(): - lower_dax_model_expressions(model, graph) - - for metric in graph.metrics.values(): - _lower_metric_dax(metric, graph, model=None) - - -def lower_dax_model_expressions(model: Model, graph: SemanticGraph) -> None: - """Lower DAX table, dimension, and metric expressions for a model.""" - _lower_model_table_dax(model, graph) - - for dimension in model.dimensions: - source = _dax_source(dimension) - if source is None: - continue - - expr = _parse_dax_expression(source, f"dimension '{model.name}.{dimension.name}'") - context = _build_dax_translation_context(graph) - try: - dimension.sql = translate_dax_scalar(expr, model.name, **_scalar_context(context)) - except DaxTranslationError as exc: - raise DaxModelingError(f"DAX dimension '{model.name}.{dimension.name}' is unsupported: {exc}") from exc - dimension.dax = source - dimension.expression_language = "dax" - setattr(dimension, "_dax_expression", source) - setattr(dimension, "_dax_lowered", True) - - # Lower metrics in declared order so simple base measures become available - # to later DAX metrics in the same model. - for metric in list(model.metrics): - _lower_metric_dax(metric, graph, model=model) - - -def _lower_model_table_dax(model: Model, graph: SemanticGraph) -> None: - source = _dax_source(model) - if source is None: - return - - expr = _parse_dax_expression(source, f"model '{model.name}'") - context = _build_dax_translation_context(graph) - try: - translation = translate_dax_table(expr, model_name=None, **_table_context(context)) - except DaxTranslationError as exc: - raise DaxModelingError(f"DAX model '{model.name}' is unsupported: {exc}") from exc - - model.sql = translation.sql - model.table = None - model.dax = source - model.expression_language = "dax" - setattr(model, "_dax_expression", source) - setattr(model, "_dax_lowered", True) - _append_modeling_warnings(graph, model, translation.warnings) - if translation.required_models: - setattr(model, "_dax_required_models", sorted(translation.required_models)) - - -def _lower_metric_dax(metric: Metric, graph: SemanticGraph, model: Model | None) -> None: - source = _dax_source(metric) - if source is None: - return - - context_name = f"metric '{metric.name}'" if model is None else f"metric '{model.name}.{metric.name}'" - expr = _parse_dax_expression(source, context_name) - context = _build_dax_translation_context(graph) - model_name = model.name if model is not None else _single_model_for_graph_metric(metric, graph) - try: - translation = translate_dax_metric(expr, model_name, **_metric_context(context)) - except DaxTranslationError as exc: - raise DaxModelingError(f"DAX {context_name} is unsupported: {exc}") from exc - - base_metric_ref = translation.base_metric - if ( - model is not None - and translation.type == "time_comparison" - and not base_metric_ref - and translation.inline_base_agg - and translation.inline_base_sql - ): - existing_names = {candidate.name for candidate in model.metrics} - base_name = _inline_base_metric_name(metric.name, existing_names) - model.metrics.append( - Metric( - name=base_name, - agg=translation.inline_base_agg, - sql=translation.inline_base_sql, - filters=translation.inline_base_filters or None, - ) - ) - base_metric_ref = f"{model.name}.{base_name}" - - metric.dax = source - metric.expression_language = "dax" - metric.agg = translation.agg - metric.sql = translation.sql - metric.type = translation.type - if metric.type is None and translation.sql and not translation.agg: - metric.type = "derived" - metric.base_metric = base_metric_ref - metric.comparison_type = translation.comparison_type - metric.calculation = translation.calculation - metric.time_offset = translation.time_offset - metric.window = translation.window - metric.grain_to_date = translation.grain_to_date - metric.window_order = translation.window_order - metric.filters = translation.filters or None - metric.relationship_overrides = translation.relationship_overrides or None - metric.required_models = sorted(translation.required_models) if translation.required_models else None - setattr(metric, "_dax_expression", source) - setattr(metric, "_dax_lowered", True) - - -def _single_model_for_graph_metric(metric: Metric, graph: SemanticGraph) -> str: - if len(graph.models) == 1: - return next(iter(graph.models)) - raise DaxModelingError( - f"DAX graph metric '{metric.name}' needs a model context; define it under a model or qualify it later" - ) - - -def _dax_source(obj: Any) -> str | None: - if bool(getattr(obj, "_dax_skip_native_lowering", False)): - return None - if bool(getattr(obj, "_dax_lowered", False)): - return None - - dax = getattr(obj, "dax", None) - language = getattr(obj, "expression_language", None) - - if isinstance(dax, str) and dax.strip(): - if language == "sql": - raise DaxModelingError( - f"{obj.__class__.__name__} defines dax but expression_language='sql'; " - "set expression_language='dax' or remove expression_language" - ) - return dax - if language == "dax": - sql = getattr(obj, "sql", None) - if isinstance(sql, str) and sql.strip(): - return sql - raise DaxModelingError(f"{obj.__class__.__name__} uses expression_language='dax' but has no dax/sql text") - return None - - -def _append_modeling_warnings(graph: SemanticGraph, model: Model, warnings: list[dict[str, Any]]) -> None: - if not warnings: - return - existing = getattr(graph, "import_warnings", None) - if not isinstance(existing, list): - existing = [] - graph.import_warnings = existing - for warning in warnings: - if not isinstance(warning, dict): - continue - existing.append( - { - "code": str(warning.get("code") or "dax_translation_warning"), - "context": "calculated_table", - "model": model.name, - "name": model.name, - "message": str(warning.get("message") or "DAX translation warning"), - } - ) - - -def _parse_dax_expression(source: str, context: str) -> Any: - try: - from sidemantic_dax.ast import parse_expression - except Exception as exc: - raise DaxModelingError( - "sidemantic_dax is required for DAX model definitions. Install DAX extras and retry " - "(e.g. `uv sync --extra dax`)." - ) from exc - - try: - return parse_expression(source) - except Exception as exc: - raise DaxModelingError(f"Could not parse DAX {context}: {exc}") from exc - - -def _metric_context(context: dict[str, Any]) -> dict[str, Any]: - return { - "column_sql_by_table": context["column_sql_by_table"], - "measure_names_by_table": context["measure_names_by_table"], - "measure_aggs_by_table": context["measure_aggs_by_table"], - "measure_sql_by_table": context["measure_sql_by_table"], - "measure_filters_by_table": context["measure_filters_by_table"], - "time_dimensions_by_table": context["time_dimensions_by_table"], - "relationship_edges": context["relationship_edges"], - } - - -def _scalar_context(context: dict[str, Any]) -> dict[str, Any]: - return { - "column_sql_by_table": context["column_sql_by_table"], - "measure_names_by_table": context["measure_names_by_table"], - "time_dimensions_by_table": context["time_dimensions_by_table"], - } - - -def _table_context(context: dict[str, Any]) -> dict[str, Any]: - return { - "column_sql_by_table": context["column_sql_by_table"], - "measure_names_by_table": context["measure_names_by_table"], - "relationship_edges": context["relationship_edges"], - } - - -def _inline_base_metric_name(metric_name: str, existing_names: set[str]) -> str: - stem_chars: list[str] = [] - for ch in metric_name: - stem_chars.append(ch.lower() if ch.isalnum() else "_") - stem = "".join(stem_chars).strip("_") or "metric" - candidate = f"__{stem}_base" - if candidate not in existing_names: - return candidate - idx = 2 - while f"{candidate}_{idx}" in existing_names: - idx += 1 - return f"{candidate}_{idx}" - - -def _build_dax_translation_context(graph: SemanticGraph) -> dict[str, Any]: - column_sql_by_table: dict[str, dict[str, str]] = {} - measure_names_by_table: dict[str, set[str]] = {} - measure_aggs_by_table: dict[str, dict[str, str]] = {} - measure_sql_by_table: dict[str, dict[str, str]] = {} - measure_filters_by_table: dict[str, dict[str, list[str]]] = {} - time_dimensions_by_table: dict[str, set[str]] = {} - - for model_name, model in graph.models.items(): - column_sql_by_table[model_name] = {dim.name: (dim.sql or dim.name) for dim in model.dimensions} - measure_names_by_table[model_name] = {metric.name for metric in model.metrics} - measure_aggs_by_table[model_name] = { - metric.name: metric.agg for metric in model.metrics if metric.agg and not _is_unlowered_dax_metric(metric) - } - measure_sql_by_table[model_name] = { - metric.name: metric.sql for metric in model.metrics if metric.sql and not _is_unlowered_dax_metric(metric) - } - measure_filters_by_table[model_name] = { - metric.name: list(metric.filters or []) - for metric in model.metrics - if metric.filters and not _is_unlowered_dax_metric(metric) - } - time_dimensions_by_table[model_name] = {dim.name for dim in model.dimensions if dim.type == "time"} - - edges: list[RelationshipEdge] = [] - seen_edges: set[tuple[str, str, str, str]] = set() - for model_name, model in graph.models.items(): - for rel in model.relationships: - if not rel.active: - continue - related_model = graph.models.get(rel.name) - if related_model is None: - continue - - if rel.type == "many_to_one": - from_column = rel.foreign_key or rel.sql_expr - to_column = rel.primary_key or related_model.primary_key - elif rel.type in ("one_to_many", "one_to_one"): - from_column = _relationship_tmdl_from_column(rel) or model.primary_key - to_column = rel.foreign_key or rel.sql_expr - elif rel.type == "many_to_many": - from_column = rel.foreign_key or rel.sql_expr - to_column = rel.primary_key or related_model.primary_key - else: - continue - - if not from_column or not to_column or not isinstance(from_column, str) or not isinstance(to_column, str): - continue - - key = (model_name.lower(), from_column.lower(), related_model.name.lower(), to_column.lower()) - reverse = (related_model.name.lower(), to_column.lower(), model_name.lower(), from_column.lower()) - if key in seen_edges or reverse in seen_edges: - continue - - seen_edges.add(key) - edges.append( - RelationshipEdge( - from_table=model_name, - from_column=from_column, - to_table=related_model.name, - to_column=to_column, - ) - ) - - return { - "column_sql_by_table": column_sql_by_table, - "measure_names_by_table": measure_names_by_table, - "measure_aggs_by_table": measure_aggs_by_table, - "measure_sql_by_table": measure_sql_by_table, - "measure_filters_by_table": measure_filters_by_table, - "time_dimensions_by_table": time_dimensions_by_table, - "relationship_edges": edges, - } - - -def _relationship_tmdl_from_column(rel: Any) -> str | None: - value = getattr(rel, "_tmdl_from_column", None) - if isinstance(value, str) and value.strip(): - return value - return None - - -def _is_unlowered_dax_metric(metric: Metric) -> bool: - return metric.expression_language == "dax" and not bool(getattr(metric, "_dax_lowered", False)) diff --git a/sidemantic/dax/runtime.py b/sidemantic/dax/runtime.py deleted file mode 100644 index cd659669..00000000 --- a/sidemantic/dax/runtime.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Runtime helpers for parsing/translating DAX against a semantic graph.""" - -from __future__ import annotations - -from typing import Any - -from sidemantic.core.semantic_graph import SemanticGraph -from sidemantic.dax import RelationshipEdge, translate_dax_query - - -def parse_dax_query(text: str) -> Any: - """Parse raw DAX query text into sidemantic_dax AST.""" - try: - import sidemantic_dax - except Exception as exc: - raise RuntimeError( - "sidemantic_dax is required for DAX query execution. Install DAX extras and retry (e.g. `uv sync --extra dax`)." - ) from exc - - try: - return sidemantic_dax.parse_query(text) - except RuntimeError as exc: - if "native module is not available" in str(exc): - raise RuntimeError( - "sidemantic_dax native module is not available. Rebuild/install DAX extras (e.g. `uv sync --extra dax`)." - ) from exc - raise - - -def build_dax_translation_context(graph: SemanticGraph) -> dict[str, Any]: - """Build query translation context from graph models/relationships.""" - column_sql_by_table: dict[str, dict[str, str]] = {} - measure_names_by_table: dict[str, set[str]] = {} - measure_aggs_by_table: dict[str, dict[str, str]] = {} - measure_sql_by_table: dict[str, dict[str, str]] = {} - measure_filters_by_table: dict[str, dict[str, list[str]]] = {} - time_dimensions_by_table: dict[str, set[str]] = {} - - for model_name, model in graph.models.items(): - column_sql_by_table[model_name] = {dim.name: (dim.sql or dim.name) for dim in model.dimensions} - measure_names_by_table[model_name] = {metric.name for metric in model.metrics} - measure_aggs_by_table[model_name] = {metric.name: metric.agg for metric in model.metrics if metric.agg} - measure_sql_by_table[model_name] = {metric.name: metric.sql for metric in model.metrics if metric.sql} - measure_filters_by_table[model_name] = { - metric.name: list(metric.filters or []) for metric in model.metrics if metric.filters - } - time_dimensions_by_table[model_name] = {dim.name for dim in model.dimensions if dim.type == "time"} - - edges: list[RelationshipEdge] = [] - seen_edges: set[tuple[str, str, str, str]] = set() - for model_name, model in graph.models.items(): - for rel in model.relationships: - if not rel.active: - continue - related_model = graph.models.get(rel.name) - if related_model is None: - continue - - if rel.type == "many_to_one": - from_column = rel.foreign_key or rel.sql_expr - to_column = rel.primary_key or related_model.primary_key - elif rel.type in ("one_to_many", "one_to_one"): - from_column = _relationship_tmdl_from_column(rel) or model.primary_key - to_column = rel.foreign_key or rel.sql_expr - elif rel.type == "many_to_many": - from_column = rel.foreign_key or rel.sql_expr - to_column = rel.primary_key or related_model.primary_key - else: - continue - - if not from_column or not to_column: - continue - - key = (model_name.lower(), from_column.lower(), related_model.name.lower(), to_column.lower()) - reverse = (related_model.name.lower(), to_column.lower(), model_name.lower(), from_column.lower()) - if key in seen_edges or reverse in seen_edges: - continue - - seen_edges.add(key) - edges.append( - RelationshipEdge( - from_table=model_name, - from_column=from_column, - to_table=related_model.name, - to_column=to_column, - ) - ) - - return { - "column_sql_by_table": column_sql_by_table, - "measure_names_by_table": measure_names_by_table, - "measure_aggs_by_table": measure_aggs_by_table, - "measure_sql_by_table": measure_sql_by_table, - "measure_filters_by_table": measure_filters_by_table, - "time_dimensions_by_table": time_dimensions_by_table, - "relationship_edges": edges, - } - - -def _relationship_tmdl_from_column(rel: Any) -> str | None: - value = getattr(rel, "_tmdl_from_column", None) - if isinstance(value, str) and value.strip(): - return value - return None - - -def translate_dax_query_ast(query_ast: Any, graph: SemanticGraph) -> Any: - """Translate parsed DAX query AST into executable SQL payloads.""" - context = build_dax_translation_context(graph) - return translate_dax_query(query_ast, **context) diff --git a/sidemantic/dax/translator.py b/sidemantic/dax/translator.py deleted file mode 100644 index 1f8d6bcd..00000000 --- a/sidemantic/dax/translator.py +++ /dev/null @@ -1,7367 +0,0 @@ -"""Translate DAX AST into Sidemantic-friendly SQL and metadata.""" - -from __future__ import annotations - -import re -from collections import deque -from collections.abc import Iterable -from contextlib import nullcontext -from dataclasses import dataclass, field -from typing import Any - -from sidemantic.core.relationship import RelationshipOverride - - -class DaxTranslationError(ValueError): - pass - - -@dataclass(frozen=True) -class ColumnRef: - table: str | None - column: str - - -@dataclass(frozen=True) -class FilterClause: - sql: str - columns: frozenset[ColumnRef] - keep: bool = False - - -@dataclass(frozen=True) -class FilterRemoval: - table: str | None = None - column: str | None = None - - -@dataclass(frozen=True) -class FilterRetention: - table: str - columns: frozenset[str] - - -@dataclass -class MetricTranslation: - sql: str | None - agg: str | None = None - type: str | None = None - source_table: str | None = None - base_metric: str | None = None - inline_base_sql: str | None = None - inline_base_agg: str | None = None - inline_base_filters: list[str] = field(default_factory=list) - comparison_type: str | None = None - calculation: str | None = None - time_offset: str | None = None - window: str | None = None - grain_to_date: str | None = None - window_order: str | None = None - filters: list[str] = field(default_factory=list) - relationship_overrides: list[RelationshipOverride] = field(default_factory=list) - required_models: set[str] = field(default_factory=set) - - -@dataclass -class TableTranslation: - sql: str - required_models: set[str] = field(default_factory=set) - warnings: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass -class QueryEvaluateTranslation: - sql: str - required_models: set[str] = field(default_factory=set) - warnings: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass -class QueryTranslation: - evaluates: list[QueryEvaluateTranslation] - warnings: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass(frozen=True) -class RelationshipEdge: - from_table: str - from_column: str - to_table: str - to_column: str - - -@dataclass(frozen=True) -class TableColumnArg: - name: str - table: str | None = None - - -def translate_dax_metric( - expr: Any, - model_name: str, - column_sql_by_table: dict[str, dict[str, str]] | None = None, - measure_names_by_table: dict[str, set[str]] | None = None, - measure_aggs_by_table: dict[str, dict[str, str]] | None = None, - measure_sql_by_table: dict[str, dict[str, str]] | None = None, - measure_filters_by_table: dict[str, dict[str, list[str]]] | None = None, - time_dimensions_by_table: dict[str, set[str]] | None = None, - relationship_edges: list[RelationshipEdge] | None = None, -) -> MetricTranslation: - dax_ast = _load_dax_ast() - translator = _DaxTranslator( - dax_ast, - model_name=model_name, - column_sql_by_table=column_sql_by_table or {}, - measure_names_by_table=measure_names_by_table or {}, - measure_aggs_by_table=measure_aggs_by_table or {}, - measure_sql_by_table=measure_sql_by_table or {}, - measure_filters_by_table=measure_filters_by_table or {}, - time_dimensions_by_table=time_dimensions_by_table or {}, - relationship_edges=relationship_edges or [], - ) - return translator.translate_metric(expr) - - -def translate_dax_table( - expr: Any, - model_name: str | None = None, - column_sql_by_table: dict[str, dict[str, str]] | None = None, - measure_names_by_table: dict[str, set[str]] | None = None, - relationship_edges: list[RelationshipEdge] | None = None, -) -> TableTranslation: - dax_ast = _load_dax_ast() - translator = _DaxTranslator( - dax_ast, - model_name=model_name, - column_sql_by_table=column_sql_by_table or {}, - measure_names_by_table=measure_names_by_table or {}, - measure_aggs_by_table={}, - measure_sql_by_table={}, - measure_filters_by_table={}, - time_dimensions_by_table={}, - relationship_edges=relationship_edges or [], - allow_unrelated_table_cross_join=True, - ) - return translator.translate_table(expr) - - -def translate_dax_scalar( - expr: Any, - model_name: str | None = None, - column_sql_by_table: dict[str, dict[str, str]] | None = None, - measure_names_by_table: dict[str, set[str]] | None = None, - time_dimensions_by_table: dict[str, set[str]] | None = None, -) -> str: - dax_ast = _load_dax_ast() - translator = _DaxTranslator( - dax_ast, - model_name=model_name, - column_sql_by_table=column_sql_by_table or {}, - measure_names_by_table=measure_names_by_table or {}, - measure_aggs_by_table={}, - measure_sql_by_table={}, - measure_filters_by_table={}, - time_dimensions_by_table=time_dimensions_by_table or {}, - relationship_edges=[], - ) - context = translator._allow_cross_table_context() if model_name is None else nullcontext() - with context: - return translator._translate_scalar(expr).sql - - -def translate_dax_query( - query: Any, - column_sql_by_table: dict[str, dict[str, str]] | None = None, - measure_names_by_table: dict[str, set[str]] | None = None, - measure_aggs_by_table: dict[str, dict[str, str]] | None = None, - measure_sql_by_table: dict[str, dict[str, str]] | None = None, - measure_filters_by_table: dict[str, dict[str, list[str]]] | None = None, - time_dimensions_by_table: dict[str, set[str]] | None = None, - relationship_edges: list[RelationshipEdge] | None = None, -) -> QueryTranslation: - dax_ast = _load_dax_ast() - if not isinstance(query, dax_ast.Query): - raise DaxTranslationError("translate_dax_query expects sidemantic_dax.ast.Query") - - resolver = _DefineResolver(dax_ast, query.define) - evaluates: list[QueryEvaluateTranslation] = [] - for stmt in query.evaluates: - translator = _DaxTranslator( - dax_ast, - model_name=None, - column_sql_by_table=column_sql_by_table or {}, - measure_names_by_table=measure_names_by_table or {}, - measure_aggs_by_table=measure_aggs_by_table or {}, - measure_sql_by_table=measure_sql_by_table or {}, - measure_filters_by_table=measure_filters_by_table or {}, - time_dimensions_by_table=time_dimensions_by_table or {}, - relationship_edges=relationship_edges or [], - allow_unrelated_table_cross_join=True, - ) - statement_expr = resolver.resolve_expr(stmt.expr) - sql = translator._translate_table(statement_expr) - order_keys = _translate_order_keys(stmt, translator, resolver) - sql = _apply_order_and_start_at(sql, stmt, translator, resolver, order_keys) - evaluates.append( - QueryEvaluateTranslation( - sql=sql, - required_models=set(translator._required_models), - warnings=translator.warnings, - ) - ) - - return QueryTranslation(evaluates=evaluates) - - -class _DaxTranslator: - def __init__( - self, - dax_ast: Any, - model_name: str | None, - column_sql_by_table: dict[str, dict[str, str]], - measure_names_by_table: dict[str, set[str]], - measure_aggs_by_table: dict[str, dict[str, str]], - measure_sql_by_table: dict[str, dict[str, str]], - measure_filters_by_table: dict[str, dict[str, list[str]]], - time_dimensions_by_table: dict[str, set[str]], - relationship_edges: list[RelationshipEdge], - allow_unrelated_table_cross_join: bool = False, - ) -> None: - self.dax = dax_ast - self.model_name = model_name - self.column_sql_by_table = column_sql_by_table - self.measure_names_by_table = measure_names_by_table - self.measure_aggs_by_table = measure_aggs_by_table - self.measure_sql_by_table = measure_sql_by_table - self.measure_filters_by_table = measure_filters_by_table - self.time_dimensions_by_table = time_dimensions_by_table - self._env: dict[str, _SqlFragment] = {} - self._required_models: set[str] = set() - self._relationship_overrides: list[RelationshipOverride] = [] - self._base_table: str | None = None - self._allow_cross_table = False - self._prefer_unqualified_base_table = False - self._relationship_edges = relationship_edges - self._relationship_adjacency = self._build_relationship_adjacency(relationship_edges) - self._allow_unrelated_table_cross_join = allow_unrelated_table_cross_join - self._current_group_by_columns: frozenset[ColumnRef] = frozenset() - self._current_filter_columns: frozenset[ColumnRef] = frozenset() - self._warnings: list[dict[str, Any]] = [] - self._warning_keys: set[tuple[str, str, str]] = set() - - @property - def warnings(self) -> list[dict[str, Any]]: - return [dict(warning) for warning in self._warnings] - - def translate_metric(self, expr: Any) -> MetricTranslation: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.VarBlock): - return self._translate_var_block_metric(expr) - - metric_ref = self._translate_metric_reference(expr) - if metric_ref is not None: - return metric_ref - - if isinstance(expr, self.dax.FunctionCall): - name = expr.name.lower() - if name == "calculate": - return self._translate_calculate(expr) - if name in ("totalytd", "totalmtd", "totalqtd", "totalwtd"): - return self._translate_total_to_date(expr) - if name in ("min", "max"): - if len(expr.args) == 1: - return self._translate_aggregate(expr) - with self._allow_cross_table_context(): - sql = self._translate_min_max(expr).sql - return MetricTranslation(sql=sql, type="derived", required_models=set(self._required_models)) - if name in ( - "sum", - "average", - "averagea", - "avg", - "mina", - "maxa", - "median", - "count", - "countrows", - "counta", - "countblank", - "distinctcount", - "distinctcountnoblank", - "approximatedistinctcount", - ): - return self._translate_aggregate(expr) - if name in ("sumx", "averagex", "avgx", "minx", "maxx", "medianx", "countx", "countax"): - return self._translate_iter_aggregate(expr) - - if isinstance(expr, self.dax.Paren): - return self.translate_metric(expr.expr) - - with self._allow_cross_table_context(): - sql = self._translate_scalar(expr).sql - return MetricTranslation(sql=sql, type="derived", required_models=set(self._required_models)) - - def _translate_var_block_metric(self, var_block: Any) -> MetricTranslation: - prior_env = dict(self._env) - metric_vars: dict[str, MetricTranslation] = {} - - try: - for decl in var_block.decls: - metric_value = None - try: - metric_value = self.translate_metric(decl.expr) - except DaxTranslationError: - metric_value = None - if metric_value is not None: - metric_vars[decl.name.lower()] = metric_value - - try: - with self._allow_cross_table_context(): - scalar_value = self._translate_scalar(decl.expr) - except DaxTranslationError: - # Keep metric-only vars available for RETURN var metric paths. - # Some metric vars (for example time-intelligence CALCULATE) do not map to scalar SQL. - if metric_value is not None: - continue - raise - self._env[decl.name.lower()] = scalar_value - - body = self._unwrap(var_block.body) - if isinstance(body, self.dax.Identifier): - key = body.name.lower() - if key in metric_vars: - result = metric_vars[key] - result.required_models.update(self._required_models) - return result - if isinstance(body, self.dax.BracketRef): - key = body.name.lower() - if key in metric_vars: - result = metric_vars[key] - result.required_models.update(self._required_models) - return result - - return MetricTranslation( - sql=self._translate_projection_scalar(var_block.body).sql, - type="derived", - required_models=set(self._required_models), - ) - finally: - self._env = prior_env - - def translate_table(self, expr: Any) -> TableTranslation: - sql = self._translate_table(expr) - return TableTranslation(sql=sql, required_models=set(self._required_models), warnings=self.warnings) - - def _translate_calculate(self, call: Any) -> MetricTranslation: - if not call.args: - raise DaxTranslationError("CALCULATE requires an expression") - - base_expr = call.args[0] - filter_args = call.args[1:] - - time_translation = self._translate_calculate_time_intelligence(base_expr, filter_args) - if time_translation is not None: - return time_translation - - base_metric = self.translate_metric(base_expr) - - new_filters, removals, retentions, remove_all, clear_non_keep, overrides = self._translate_filter_args( - filter_args - ) - inherited_filters = [self._filter_clause_from_sql(sql, keep=True) for sql in (base_metric.filters or [])] - combined_filters = self._merge_filter_clauses(inherited_filters, new_filters) - combined_filters = self._apply_non_keep_clear(combined_filters, remove_all, clear_non_keep) - filters = self._apply_filter_retentions(combined_filters, retentions, remove_all) - filters = self._apply_filter_removals(filters, removals, remove_all) - - base_metric.relationship_overrides.extend(overrides) - base_metric.required_models.update(self._required_models) - base_metric.filters = [] - - if base_metric.type in ("time_comparison", "cumulative"): - if filters: - base_metric.filters.extend(filters) - return base_metric - - if base_metric.agg: - base_metric.filters.extend(filters) - return base_metric - - if filters and base_metric.sql: - predicate = " AND ".join(filters) - base_metric.sql = f"CASE WHEN {predicate} THEN {base_metric.sql} ELSE NULL END" - return base_metric - - def _translate_calculate_time_intelligence( - self, base_expr: Any, filter_args: list[Any] - ) -> MetricTranslation | None: - if not filter_args: - return None - - time_filter_idx: int | None = None - time_filter: Any | None = None - for idx, arg in enumerate(filter_args): - candidate = self._extract_time_filter_call(arg) - if candidate is None: - continue - time_filter_idx = idx - time_filter = candidate - break - - if time_filter is None or time_filter_idx is None: - return None - - name = time_filter.name.lower() - if time_filter.args: - with self._allow_cross_table_context(): - window_order = self._translate_scalar(time_filter.args[0]).sql - else: - window_order = None - base_metric = self._extract_measure_reference(base_expr) - inline_base_sql = None - inline_base_agg = None - inline_base_filters: list[str] = [] - if not base_metric: - base_translation = self.translate_metric(base_expr) - if not base_translation.agg or not base_translation.sql: - return None - inline_base_sql = base_translation.sql - inline_base_agg = base_translation.agg - inline_base_filters = list(base_translation.filters or []) - - if name in ("datesytd", "datesmtd", "datesqtd", "dateswtd"): - self._validate_time_argument(time_filter.args[0] if time_filter.args else None) - grain = { - "datesytd": "year", - "datesmtd": "month", - "datesqtd": "quarter", - "dateswtd": "week", - }[name] - - base_translation = self.translate_metric(base_expr) - if base_translation.agg: - translation = MetricTranslation( - sql=base_translation.sql, - agg=base_translation.agg, - type="cumulative", - grain_to_date=grain, - window_order=window_order, - required_models=set(self._required_models), - ) - else: - base_ref = base_translation.base_metric or self._extract_measure_reference(base_expr) - if base_ref: - base_agg = self._lookup_measure_agg(base_ref) - translation = MetricTranslation( - sql=base_ref, - agg=base_agg, - type="cumulative", - grain_to_date=grain, - window_order=window_order, - required_models=set(self._required_models), - ) - elif base_translation.sql: - translation = MetricTranslation( - sql=base_translation.sql, - agg=base_translation.agg, - type="cumulative", - grain_to_date=grain, - window_order=window_order, - required_models=set(self._required_models), - ) - else: - return None - - translation.relationship_overrides = list(base_translation.relationship_overrides or []) - translation.required_models.update(base_translation.required_models) - if base_translation.filters: - translation.filters = list(base_translation.filters) - elif name == "datesinperiod": - self._validate_time_argument(time_filter.args[0] if time_filter.args else None) - if len(time_filter.args) < 4: - return None - periods = self._number_literal_value(time_filter.args[2]) - unit = self._identifier_literal_value(time_filter.args[3]) - if periods is None or unit is None: - return None - - normalized_unit = unit.lower() - if normalized_unit.endswith("s"): - normalized_unit = normalized_unit[:-1] - if periods > 0: - window = f"{periods} {normalized_unit} following" - else: - window = f"{abs(periods)} {normalized_unit}" - - base_translation = self.translate_metric(base_expr) - if base_translation.agg: - translation = MetricTranslation( - sql=base_translation.sql, - agg=base_translation.agg, - type="cumulative", - window=window, - window_order=window_order, - required_models=set(self._required_models), - ) - else: - base_ref = base_translation.base_metric or self._extract_measure_reference(base_expr) - if base_ref: - base_agg = self._lookup_measure_agg(base_ref) - translation = MetricTranslation( - sql=base_ref, - agg=base_agg, - type="cumulative", - window=window, - window_order=window_order, - required_models=set(self._required_models), - ) - elif base_translation.sql: - translation = MetricTranslation( - sql=base_translation.sql, - agg=base_translation.agg, - type="cumulative", - window=window, - window_order=window_order, - required_models=set(self._required_models), - ) - else: - return None - - translation.relationship_overrides = list(base_translation.relationship_overrides or []) - translation.required_models.update(base_translation.required_models) - if base_translation.filters: - translation.filters = list(base_translation.filters) - elif name == "sameperiodlastyear": - self._validate_time_argument(time_filter.args[0] if time_filter.args else None) - translation = MetricTranslation( - sql=None, - type="time_comparison", - base_metric=base_metric, - inline_base_sql=inline_base_sql, - inline_base_agg=inline_base_agg, - inline_base_filters=inline_base_filters, - comparison_type="yoy", - calculation="previous_value", - window_order=window_order, - required_models=set(self._required_models), - ) - elif name in ("dateadd", "parallelperiod"): - self._validate_time_argument(time_filter.args[0] if time_filter.args else None) - time_info = self._parse_dateadd(time_filter) - if not time_info: - return None - offset, unit = time_info - comparison_type = _comparison_type_for_unit(unit) - translation = MetricTranslation( - sql=None, - type="time_comparison", - base_metric=base_metric, - inline_base_sql=inline_base_sql, - inline_base_agg=inline_base_agg, - inline_base_filters=inline_base_filters, - comparison_type=comparison_type, - calculation="previous_value", - time_offset=f"{offset} {unit}", - window_order=window_order, - required_models=set(self._required_models), - ) - else: - self._validate_time_argument(time_filter.args[0] if time_filter.args else None) - time_info = _time_offset_for_period_function(name) - if time_info is None: - return None - offset, unit = time_info - comparison_type = _comparison_type_for_unit(unit) - translation = MetricTranslation( - sql=None, - type="time_comparison", - base_metric=base_metric, - inline_base_sql=inline_base_sql, - inline_base_agg=inline_base_agg, - inline_base_filters=inline_base_filters, - comparison_type=comparison_type, - calculation="previous_value", - time_offset=f"{offset} {unit}", - window_order=window_order, - required_models=set(self._required_models), - ) - - remaining = [arg for idx, arg in enumerate(filter_args) if idx != time_filter_idx] - if not remaining: - return translation - - new_filters, removals, retentions, remove_all, clear_non_keep, overrides = self._translate_filter_args( - remaining - ) - combined_filters = self._merge_filter_clauses([], new_filters) - combined_filters = self._apply_non_keep_clear(combined_filters, remove_all, clear_non_keep) - retained_filters = self._apply_filter_retentions(combined_filters, retentions, remove_all) - translation.filters = self._apply_filter_removals(retained_filters, removals, remove_all) - translation.relationship_overrides.extend(overrides) - translation.required_models.update(self._required_models) - return translation - - return None - - def _extract_time_filter_call(self, expr: Any) -> Any | None: - candidate = self._unwrap(expr) - if not isinstance(candidate, self.dax.FunctionCall): - return None - - name = candidate.name.lower() - if name in ( - "sameperiodlastyear", - "dateadd", - "parallelperiod", - "datesytd", - "datesmtd", - "datesqtd", - "dateswtd", - "datesinperiod", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - ): - return candidate - - if name == "keepfilters" and candidate.args: - inner = self._unwrap(candidate.args[0]) - if isinstance(inner, self.dax.FunctionCall) and inner.name.lower() in ( - "sameperiodlastyear", - "dateadd", - "parallelperiod", - "datesytd", - "datesmtd", - "datesqtd", - "dateswtd", - "datesinperiod", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - ): - return inner - - return None - - def _translate_total_to_date(self, call: Any) -> MetricTranslation: - if not call.args: - raise DaxTranslationError("TOTAL* function requires an expression") - if len(call.args) > 1: - self._validate_time_argument(call.args[1]) - if len(call.args) > 1: - with self._allow_cross_table_context(): - window_order = self._translate_scalar(call.args[1]).sql - else: - window_order = None - - extra_filter_args: list[Any] = [] - if len(call.args) > 2: - for arg in call.args[2:]: - # TOTALYTD optionally accepts a year-end literal after filter args. - if isinstance(self._unwrap(arg), self.dax.String): - continue - extra_filter_args.append(arg) - - base_expr = call.args[0] - grain = { - "totalytd": "year", - "totalmtd": "month", - "totalqtd": "quarter", - "totalwtd": "week", - }.get(call.name.lower()) - - base_metric = self.translate_metric(base_expr) - translation: MetricTranslation - if base_metric.agg: - translation = MetricTranslation( - sql=base_metric.sql, - agg=base_metric.agg, - type="cumulative", - grain_to_date=grain, - window_order=window_order, - required_models=set(self._required_models), - ) - else: - base_ref = base_metric.base_metric or self._extract_measure_reference(base_expr) - if base_ref: - base_agg = self._lookup_measure_agg(base_ref) - translation = MetricTranslation( - sql=base_ref, - agg=base_agg, - type="cumulative", - grain_to_date=grain, - window_order=window_order, - required_models=set(self._required_models), - ) - elif base_metric.sql: - translation = MetricTranslation( - sql=base_metric.sql, - agg=base_metric.agg, - type="cumulative", - grain_to_date=grain, - window_order=window_order, - required_models=set(self._required_models), - ) - else: - raise DaxTranslationError("Unsupported TOTAL* expression") - - inherited_filters = [self._filter_clause_from_sql(sql, keep=True) for sql in (base_metric.filters or [])] - ( - new_filters, - removals, - retentions, - remove_all, - clear_non_keep, - overrides, - ) = self._translate_filter_args(extra_filter_args) - combined_filters = self._merge_filter_clauses(inherited_filters, new_filters) - combined_filters = self._apply_non_keep_clear(combined_filters, remove_all, clear_non_keep) - retained_filters = self._apply_filter_retentions(combined_filters, retentions, remove_all) - translation.filters = self._apply_filter_removals(retained_filters, removals, remove_all) - - translation.relationship_overrides = [*base_metric.relationship_overrides, *overrides] - translation.required_models.update(base_metric.required_models) - translation.required_models.update(self._required_models) - return translation - - def _translate_aggregate(self, call: Any) -> MetricTranslation: - name = call.name.lower() - agg_map = { - "sum": "sum", - "average": "avg", - "averagea": "avg", - "avg": "avg", - "min": "min", - "mina": "min", - "max": "max", - "maxa": "max", - "median": "median", - "count": "count", - "countrows": "count", - "counta": "count", - "countblank": "count", - "distinctcount": "count_distinct", - "distinctcountnoblank": "count_distinct", - "approximatedistinctcount": "count_distinct", - } - agg = agg_map.get(name) - if agg is None: - raise DaxTranslationError(f"Unsupported aggregate {call.name}") - - if name == "countrows": - if len(call.args) > 1: - raise DaxTranslationError("COUNTROWS supports at most one argument") - if call.args: - distinct_translation = self._translate_countrows_distinct_table(call.args[0]) - if distinct_translation is not None: - return distinct_translation - with self._allow_cross_table_context(): - filters, overrides = self._filters_from_table(call.args[0]) - return MetricTranslation( - sql=None, - agg=agg, - filters=filters, - relationship_overrides=overrides, - required_models=set(self._required_models), - ) - return MetricTranslation(sql=None, agg=agg, required_models=set(self._required_models)) - - if not call.args: - raise DaxTranslationError(f"{call.name} requires an argument") - if len(call.args) > 1: - raise DaxTranslationError(f"{call.name} supports exactly one argument") - - prefer_unqualified_base = self.model_name is None and not self._allow_cross_table - nested_context = self._prefer_unqualified_base_table_context() if prefer_unqualified_base else nullcontext() - with self._allow_cross_table_context(): - with nested_context: - arg_sql = self._translate_scalar(call.args[0]) - if name == "countblank": - return MetricTranslation( - sql=f"CASE WHEN {arg_sql.sql} IS NULL THEN 1 END", - agg=agg, - required_models=set(self._required_models), - ) - return MetricTranslation(sql=arg_sql.sql, agg=agg, required_models=set(self._required_models)) - - def _translate_countrows_distinct_table(self, table_expr: Any) -> MetricTranslation | None: - table_expr = self._unwrap(table_expr) - if not isinstance(table_expr, self.dax.FunctionCall): - return None - if table_expr.name.lower() not in ("values", "filters", "distinct"): - return None - if not table_expr.args: - return None - - target = self._unwrap(table_expr.args[0]) - if not isinstance( - target, - ( - self.dax.TableColumnRef, - self.dax.HierarchyRef, - self.dax.BracketRef, - self.dax.Identifier, - ), - ): - return None - - with self._allow_cross_table_context(): - column_sql = self._translate_scalar(target) - return MetricTranslation( - sql=column_sql.sql, - agg="count_distinct", - required_models=set(self._required_models), - ) - - def _translate_iter_aggregate(self, call: Any) -> MetricTranslation: - name = call.name.lower() - agg_map = { - "sumx": "sum", - "averagex": "avg", - "avgx": "avg", - "minx": "min", - "maxx": "max", - "medianx": "median", - "countx": "count", - "countax": "count", - } - agg = agg_map[name] - if len(call.args) < 2: - raise DaxTranslationError(f"{call.name} requires a table and expression") - - table_expr = call.args[0] - row_expr = call.args[1] - - with self._allow_cross_table_context(): - table_target = self._unwrap(table_expr) - if isinstance(table_target, self.dax.FunctionCall) and table_target.name.lower() == "currentgroup": - filters = [] - overrides = [] - else: - filters, overrides = self._filters_from_table(table_expr) - row_sql = self._translate_scalar(row_expr) - return MetricTranslation( - sql=row_sql.sql, - agg=agg, - filters=filters, - relationship_overrides=overrides, - required_models=set(self._required_models), - ) - - def _translate_filter_args( - self, args: Iterable[Any] - ) -> tuple[ - list[FilterClause], - list[FilterRemoval], - list[FilterRetention], - bool, - bool, - list[RelationshipOverride], - ]: - filters: list[FilterClause] = [] - removals: list[FilterRemoval] = [] - retentions: list[FilterRetention] = [] - remove_all = False - clear_non_keep = False - overrides: list[RelationshipOverride] = [] - - for arg in args: - arg = self._unwrap(arg) - if isinstance(arg, self.dax.FunctionCall): - name = arg.name.lower() - if name == "keepfilters": - inner = arg.args[0] if arg.args else None - if inner is None: - continue - candidate_filters, candidate_overrides = self._translate_filter_candidate(inner, keep=True) - filters.extend(candidate_filters) - overrides.extend(candidate_overrides) - continue - if name in ("removefilters", "all", "allnoblankrow", "allselected", "allcrossfiltered"): - if not arg.args: - if name == "allselected": - clear_non_keep = True - else: - remove_all = True - continue - for target in arg.args: - removal = self._translate_filter_removal(target) - if removal: - removals.append(removal) - continue - if name == "allexcept": - retention = self._translate_allexcept(arg) - if retention is not None: - retentions.append(retention) - continue - if name == "userelationship": - override = self._translate_userelationship(arg) - if override: - overrides.append(override) - continue - if name == "crossfilter": - override = self._translate_crossfilter(arg) - if override: - overrides.append(override) - continue - if name == "filter": - candidate_filters, candidate_overrides = self._translate_filter_candidate(arg, keep=False) - filters.extend(candidate_filters) - overrides.extend(candidate_overrides) - continue - candidate_filters, candidate_overrides = self._translate_filter_candidate(arg, keep=False) - filters.extend(candidate_filters) - overrides.extend(candidate_overrides) - - return filters, removals, retentions, remove_all, clear_non_keep, overrides - - def _translate_filter_candidate( - self, expr: Any, keep: bool - ) -> tuple[list[FilterClause], list[RelationshipOverride]]: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "nonvisual": - inner = self._unwrap(expr.args[0]) if expr.args else None - if inner is None: - return [], [] - return self._translate_filter_candidate(inner, keep=keep) - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "filter": - if len(expr.args) < 2: - raise DaxTranslationError("FILTER requires table and predicate") - source_expr = self._unwrap(expr.args[0]) - with self._allow_cross_table_context(): - table_filters_sql, table_overrides = self._filters_from_table(source_expr) - table_filters = [self._filter_clause_from_sql(sql, keep=keep) for sql in table_filters_sql] - predicate_clause = self._translate_predicate(expr.args[1], keep=keep) - alias_predicate = self._translate_alias_backed_filter_predicate(source_expr, expr.args[1], keep=keep) - if alias_predicate is not None: - return [*table_filters, alias_predicate], table_overrides - if self._table_name_from_expr(source_expr) is None and self._predicate_needs_derived_alias_fallback( - expr.args[1], predicate_clause - ): - with self._allow_cross_table_context(): - filtered_sql = self._translate_filter_table(expr) - exists_sql = f"EXISTS (SELECT 1 FROM ({filtered_sql}) AS __filter_table)" - # Treat derived-table alias predicates as opaque: avoid leaking inner table - # lineage into outer FROM expansion. - return [FilterClause(sql=exists_sql, columns=frozenset(), keep=keep)], table_overrides - return [*table_filters, predicate_clause], table_overrides - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "treatas": - return [self._translate_treatas_filter(expr, keep=keep)], [] - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "datesbetween": - clause = self._translate_datesbetween_filter(expr, keep=keep) - if clause is None: - return [], [] - return [clause], [] - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "datesinperiod": - clause = self._translate_datesinperiod_filter(expr, keep=keep) - if clause is None: - return [], [] - return [clause], [] - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() in ( - "datesytd", - "datesmtd", - "datesqtd", - "dateswtd", - ): - clause = self._translate_cumulative_period_filter(expr, keep=keep) - if clause is None: - return [], [] - return [clause], [] - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() in ( - "sameperiodlastyear", - "dateadd", - "parallelperiod", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - ): - clause = self._translate_relative_period_filter(expr, keep=keep) - if clause is None: - return [], [] - return [clause], [] - if isinstance(expr, self.dax.FunctionCall) and self._is_filter_table_candidate(expr): - with self._allow_cross_table_context(): - table_filters_sql, table_overrides = self._filters_from_table(expr) - table_filters = [self._filter_clause_from_sql(sql, keep=keep) for sql in table_filters_sql] - return table_filters, table_overrides - if self._is_table_filter_candidate_expr(expr): - with self._allow_cross_table_context(): - table_filters_sql, table_overrides = self._filters_from_table(expr) - table_filters = [self._filter_clause_from_sql(sql, keep=keep) for sql in table_filters_sql] - return table_filters, table_overrides - - return [self._translate_predicate(expr, keep=keep)], [] - - def _translate_treatas_filter(self, call: Any, keep: bool) -> FilterClause: - if len(call.args) < 2: - raise DaxTranslationError("TREATAS requires a table expression and at least one target column") - - source_expr = self._unwrap(call.args[0]) - target_exprs = [self._unwrap(arg) for arg in call.args[1:]] - - with self._allow_cross_table_context(): - target_fragments: list[_SqlFragment] = [self._translate_scalar(expr) for expr in target_exprs] - - target_sql = [fragment.sql for fragment in target_fragments] - target_columns: set[ColumnRef] = set() - for fragment in target_fragments: - target_columns.update(fragment.columns) - - if isinstance(source_expr, self.dax.TableConstructor): - if not source_expr.rows: - return FilterClause(sql="(1 = 0)", columns=frozenset(target_columns), keep=keep) - - width = len(source_expr.rows[0]) - if width != len(target_sql): - raise DaxTranslationError("TREATAS table column count must match target column count") - - values_sql: list[str] = [] - for row in source_expr.rows: - if len(row) != width: - raise DaxTranslationError("Table constructor rows must have the same number of values") - fragments = [self._translate_scalar(value) for value in row] - if width == 1: - values_sql.append(fragments[0].sql) - else: - values_sql.append("(" + ", ".join(fragment.sql for fragment in fragments) + ")") - - if width == 1: - predicate = f"{target_sql[0]} IN ({', '.join(values_sql)})" - else: - predicate = f"({', '.join(target_sql)}) IN ({', '.join(values_sql)})" - return FilterClause(sql=f"({predicate})", columns=frozenset(target_columns), keep=keep) - - if isinstance(source_expr, self.dax.FunctionCall) or self._table_name_from_expr(source_expr) is not None: - source_sql = self._translate_table(source_expr) - source_width = self._treatas_source_width(source_expr, source_sql) - if source_width is not None and source_width != len(target_sql): - raise DaxTranslationError("TREATAS table column count must match target column count") - left = target_sql[0] if len(target_sql) == 1 else f"({', '.join(target_sql)})" - predicate = f"{left} IN (SELECT * FROM ({source_sql}) AS treatas_values)" - return FilterClause(sql=f"({predicate})", columns=frozenset(target_columns), keep=keep) - - raise DaxTranslationError("TREATAS requires a table expression as first argument") - - def _treatas_source_width(self, source_expr: Any, source_sql: str) -> int | None: - width = _query_output_width(source_sql) - if width is not None: - return width - - return self._treatas_source_expr_width(source_expr) - - def _treatas_source_expr_width(self, source_expr: Any) -> int | None: - source_expr = self._unwrap(source_expr) - - source_table = self._table_name_from_expr(source_expr) - if source_table is not None: - column_map = self.column_sql_by_table.get(source_table, {}) - if column_map: - return len(column_map) - return None - - if not isinstance(source_expr, self.dax.FunctionCall): - return None - - name = source_expr.name.lower() - if ( - name in ("keepfilters", "nonvisual", "filter", "calculatetable", "distinct", "renamecolumns") - and source_expr.args - ): - return self._treatas_source_expr_width(source_expr.args[0]) - if name == "keepcolumns" and len(source_expr.args) >= 2: - keep_names: set[str] = set() - for raw_arg in source_expr.args[1:]: - keep_name = self._table_column_arg_name(raw_arg, function_name="KEEPCOLUMNS").lower() - keep_names.add(keep_name) - return len(keep_names) - if name == "removecolumns" and len(source_expr.args) >= 2: - source_names = self._treatas_source_expr_column_names(source_expr.args[0]) - if source_names is not None: - removed_names: set[str] = set() - for raw_arg in source_expr.args[1:]: - removed_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS").lower() - removed_names.add(removed_name) - return len(source_names - removed_names) - source_width = self._treatas_source_expr_width(source_expr.args[0]) - if source_width is None: - return None - removed_count = 0 - seen_removed: set[str] = set() - for raw_arg in source_expr.args[1:]: - removed_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS").lower() - if removed_name in seen_removed: - continue - seen_removed.add(removed_name) - removed_count += 1 - return max(source_width - removed_count, 0) - if name in ("values", "filters") and source_expr.args: - target = self._unwrap(source_expr.args[0]) - if isinstance(target, (self.dax.TableColumnRef, self.dax.HierarchyRef, self.dax.BracketRef)): - return 1 - return self._treatas_source_expr_width(target) - if ( - name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept") - and source_expr.args - ): - target = self._unwrap(source_expr.args[0]) - if isinstance(target, (self.dax.TableColumnRef, self.dax.HierarchyRef, self.dax.BracketRef)): - return 1 - return self._treatas_source_expr_width(target) - if name == "union" and len(source_expr.args) >= 2: - widths = [self._treatas_source_expr_width(arg) for arg in source_expr.args] - known_widths = [width for width in widths if width is not None] - if not known_widths: - return None - first = known_widths[0] - if any(width != first for width in known_widths): - return None - return first - if name in ("intersect", "except") and len(source_expr.args) >= 2: - left_width = self._treatas_source_expr_width(source_expr.args[0]) - right_width = self._treatas_source_expr_width(source_expr.args[1]) - if left_width is None and right_width is None: - return None - if left_width is not None and right_width is not None and left_width != right_width: - return None - return left_width if left_width is not None else right_width - if name == "crossjoin" and len(source_expr.args) >= 2: - widths = [self._treatas_source_expr_width(arg) for arg in source_expr.args] - if any(width is None for width in widths): - return None - return sum(widths) - if name in ("naturalinnerjoin", "naturalleftouterjoin") and len(source_expr.args) >= 2: - left_columns = self._treatas_source_expr_column_names(source_expr.args[0]) - right_columns = self._treatas_source_expr_column_names(source_expr.args[1]) - if left_columns is None or right_columns is None: - return None - return len(left_columns | right_columns) - if name in ("generate", "generateall") and len(source_expr.args) >= 2: - left_width = self._treatas_source_expr_width(source_expr.args[0]) - right_width = self._treatas_source_expr_width(source_expr.args[1]) - if left_width is None or right_width is None: - return None - return left_width + right_width - if name == "topn" and len(source_expr.args) >= 2: - return self._treatas_source_expr_width(source_expr.args[1]) - if name == "topnskip" and len(source_expr.args) >= 3: - return self._treatas_source_expr_width(source_expr.args[2]) - if name == "topnperlevel": - table_idx = self._topnperlevel_table_index(source_expr) - if table_idx is not None: - return self._treatas_source_expr_width(source_expr.args[table_idx]) - - return None - - def _treatas_source_expr_column_names(self, source_expr: Any) -> set[str] | None: - source_expr = self._unwrap(source_expr) - - source_table = self._table_name_from_expr(source_expr) - if source_table is not None: - column_map = self.column_sql_by_table.get(source_table, {}) - if column_map: - return {column.lower() for column in column_map} - return None - - if not isinstance(source_expr, self.dax.FunctionCall): - return None - - name = source_expr.name.lower() - if name in ("keepfilters", "nonvisual", "filter", "calculatetable", "distinct") and source_expr.args: - return self._treatas_source_expr_column_names(source_expr.args[0]) - if name == "renamecolumns" and source_expr.args: - source_names = self._treatas_source_expr_column_names(source_expr.args[0]) - if source_names is None: - return None - renamed = set(source_names) - rename_args = source_expr.args[1:] - for idx in range(0, len(rename_args) - 1, 2): - source_name = self._table_column_arg_name(rename_args[idx], function_name="RENAMECOLUMNS").lower() - target_name = self._table_column_arg_name(rename_args[idx + 1], function_name="RENAMECOLUMNS").lower() - if source_name in renamed: - renamed.remove(source_name) - renamed.add(target_name) - return renamed - if name == "keepcolumns" and len(source_expr.args) >= 2: - keep_names: set[str] = set() - for raw_arg in source_expr.args[1:]: - keep_name = self._table_column_arg_name(raw_arg, function_name="KEEPCOLUMNS").lower() - keep_names.add(keep_name) - return keep_names - if name == "removecolumns" and source_expr.args: - source_names = self._treatas_source_expr_column_names(source_expr.args[0]) - if source_names is None: - return None - removed_names: set[str] = set() - for raw_arg in source_expr.args[1:]: - removed_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS").lower() - removed_names.add(removed_name) - return source_names - removed_names - if name in ("values", "filters") and source_expr.args: - target = self._unwrap(source_expr.args[0]) - if isinstance(target, self.dax.TableColumnRef): - return {target.column.lower()} - if isinstance(target, self.dax.HierarchyRef): - column = target.levels[-1] if target.levels else target.column - return {column.lower()} - if isinstance(target, self.dax.BracketRef): - return {target.name.lower()} - return self._treatas_source_expr_column_names(target) - if ( - name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept") - and source_expr.args - ): - target = self._unwrap(source_expr.args[0]) - if isinstance(target, self.dax.TableColumnRef): - return {target.column.lower()} - if isinstance(target, self.dax.HierarchyRef): - column = target.levels[-1] if target.levels else target.column - return {column.lower()} - if isinstance(target, self.dax.BracketRef): - return {target.name.lower()} - return self._treatas_source_expr_column_names(target) - return None - - def _translate_datesbetween_filter(self, call: Any, keep: bool) -> FilterClause | None: - if len(call.args) < 3: - raise DaxTranslationError("DATESBETWEEN requires a date column, start date, and end date") - - date_column_expr = self._unwrap(call.args[0]) - with self._allow_cross_table_context(): - date_column = self._translate_scalar(date_column_expr) - - start_expr = self._unwrap(call.args[1]) - end_expr = self._unwrap(call.args[2]) - - predicates: list[str] = [] - columns = set(date_column.columns) - - if not self._is_blank_expr(start_expr): - with self._allow_cross_table_context(): - start_fragment = self._translate_scalar(start_expr) - predicates.append(f"{date_column.sql} >= {start_fragment.sql}") - columns.update(start_fragment.columns) - - if not self._is_blank_expr(end_expr): - with self._allow_cross_table_context(): - end_fragment = self._translate_scalar(end_expr) - predicates.append(f"{date_column.sql} <= {end_fragment.sql}") - columns.update(end_fragment.columns) - - if not predicates: - return None - - return FilterClause(sql=f"({' AND '.join(predicates)})", columns=frozenset(columns), keep=keep) - - def _translate_datesinperiod_filter(self, call: Any, keep: bool) -> FilterClause | None: - if len(call.args) < 4: - raise DaxTranslationError( - "DATESINPERIOD requires a date column, end date, number of intervals, and interval unit" - ) - - date_column_expr = self._unwrap(call.args[0]) - with self._allow_cross_table_context(): - date_column = self._translate_scalar(date_column_expr) - end_fragment = self._translate_scalar(call.args[1]) - - periods = self._number_literal_value(call.args[2]) - unit = self._identifier_literal_value(call.args[3]) - if periods is None or unit is None: - raise DaxTranslationError("DATESINPERIOD interval count and unit must be literals") - - normalized_unit = unit.lower() - if normalized_unit.endswith("s"): - normalized_unit = normalized_unit[:-1] - if normalized_unit not in ("day", "week", "month", "quarter", "year"): - raise DaxTranslationError("DATESINPERIOD interval unit must be day, week, month, quarter, or year") - - interval_value = periods - interval_unit = normalized_unit - if normalized_unit == "quarter": - interval_value = periods * 3 - interval_unit = "month" - elif normalized_unit == "week": - interval_value = periods * 7 - interval_unit = "day" - - offset_sql = f"({end_fragment.sql} + INTERVAL '{interval_value} {interval_unit}')" - if periods < 0: - predicate = f"{date_column.sql} > {offset_sql} AND {date_column.sql} <= {end_fragment.sql}" - elif periods > 0: - predicate = f"{date_column.sql} >= {end_fragment.sql} AND {date_column.sql} < {offset_sql}" - else: - predicate = f"{date_column.sql} = {end_fragment.sql}" - - columns = set(date_column.columns) - columns.update(end_fragment.columns) - return FilterClause(sql=f"({predicate})", columns=frozenset(columns), keep=keep) - - def _translate_cumulative_period_filter(self, call: Any, keep: bool) -> FilterClause | None: - if not call.args: - raise DaxTranslationError("DATESYTD/DATESMTD/DATESQTD/DATESWTD requires a date column argument") - - grain_map = { - "datesytd": "year", - "datesmtd": "month", - "datesqtd": "quarter", - "dateswtd": "week", - } - name = call.name.lower() - grain = grain_map.get(name) - if grain is None: - raise DaxTranslationError(f"Unsupported cumulative period function {call.name}") - - date_expr = self._unwrap(call.args[0]) - with self._allow_cross_table_context(): - date_fragment = self._translate_scalar(date_expr) - - anchor_sql = self._relative_period_anchor_sql(date_fragment) - start_sql = f"DATE_TRUNC('{grain}', {anchor_sql})" - predicate = f"{date_fragment.sql} >= {start_sql} AND {date_fragment.sql} <= {anchor_sql}" - return FilterClause(sql=f"({predicate})", columns=frozenset(date_fragment.columns), keep=keep) - - def _translate_relative_period_filter(self, call: Any, keep: bool) -> FilterClause | None: - if not call.args: - raise DaxTranslationError(f"{call.name} requires a date column argument") - - date_expr = self._unwrap(call.args[0]) - with self._allow_cross_table_context(): - date_fragment = self._translate_scalar(date_expr) - - name = call.name.lower() - if name == "sameperiodlastyear": - offset_unit: tuple[int, str] | None = (1, "year") - elif name in ("dateadd", "parallelperiod"): - offset_unit = self._parse_dateadd(call) - else: - offset_unit = _time_offset_for_period_function(name) - - if offset_unit is None: - raise DaxTranslationError(f"Unsupported relative period function {call.name}") - - offset, unit = offset_unit - interval_offset = offset - interval_unit = unit - if unit == "quarter": - interval_offset = offset * 3 - interval_unit = "month" - elif unit == "week": - interval_offset = offset * 7 - interval_unit = "day" - - anchor_sql = self._relative_period_anchor_sql(date_fragment) - if offset > 0: - lower_sql = f"({anchor_sql} + INTERVAL '-{abs(interval_offset)} {interval_unit}')" - predicate = f"{date_fragment.sql} > {lower_sql} AND {date_fragment.sql} <= {anchor_sql}" - elif offset < 0: - upper_sql = f"({anchor_sql} + INTERVAL '{abs(interval_offset)} {interval_unit}')" - predicate = f"{date_fragment.sql} >= {anchor_sql} AND {date_fragment.sql} < {upper_sql}" - else: - predicate = f"{date_fragment.sql} = {anchor_sql}" - - return FilterClause(sql=f"({predicate})", columns=frozenset(date_fragment.columns), keep=keep) - - def _relative_period_anchor_sql(self, date_fragment: _SqlFragment) -> str: - if len(date_fragment.columns) != 1: - raise DaxTranslationError("Relative period filters require a single date column reference") - column_ref = next(iter(date_fragment.columns)) - table = column_ref.table - column = column_ref.column - if table is None or column is None: - raise DaxTranslationError("Relative period filters require a qualified date column reference") - with self._allow_cross_table_context(): - column_sql = self._column_sql(table, column) - table_sql = self._table_sql(table) - return f"(SELECT MAX({column_sql}) FROM {table_sql})" - - def _translate_filter_removal(self, expr: Any) -> FilterRemoval | None: - with self._allow_cross_table_context(): - expr = self._unwrap(expr) - table_name = self._table_name_from_expr(expr) - if table_name is not None: - self._ensure_table_context(table_name) - return FilterRemoval(table=table_name) - if isinstance(expr, self.dax.TableColumnRef): - self._ensure_table_context(expr.table.name) - return FilterRemoval(table=expr.table.name, column=expr.column) - if isinstance(expr, self.dax.HierarchyRef): - column = expr.levels[-1] if expr.levels else expr.column - self._ensure_table_context(expr.table.name) - return FilterRemoval(table=expr.table.name, column=column) - if isinstance(expr, self.dax.BracketRef): - return FilterRemoval(column=expr.name) - return None - - def _translate_allexcept(self, call: Any) -> FilterRetention | None: - if not call.args: - raise DaxTranslationError("ALLEXCEPT requires at least a table argument") - - with self._allow_cross_table_context(): - table_expr = self._unwrap(call.args[0]) - table_name = self._table_name_from_expr(table_expr) - if table_name is None: - raise DaxTranslationError("ALLEXCEPT first argument must be a table reference") - self._ensure_table_context(table_name) - - kept_columns: set[str] = set() - for arg in call.args[1:]: - expr = self._unwrap(arg) - if isinstance(expr, self.dax.TableColumnRef): - if expr.table.name.lower() != table_name.lower(): - raise DaxTranslationError("ALLEXCEPT columns must belong to the same table") - kept_columns.add(expr.column.lower()) - continue - if isinstance(expr, self.dax.HierarchyRef): - if expr.table.name.lower() != table_name.lower(): - raise DaxTranslationError("ALLEXCEPT columns must belong to the same table") - column = expr.levels[-1] if expr.levels else expr.column - kept_columns.add(column.lower()) - continue - if isinstance(expr, self.dax.BracketRef): - kept_columns.add(expr.name.lower()) - continue - if isinstance(expr, self.dax.Identifier): - kept_columns.add(expr.name.lower()) - continue - raise DaxTranslationError("ALLEXCEPT only supports column references after the table argument") - - return FilterRetention(table=table_name, columns=frozenset(kept_columns)) - - def _translate_userelationship(self, call: Any) -> RelationshipOverride | None: - if len(call.args) < 2: - raise DaxTranslationError("USERELATIONSHIP requires two column references") - left = self._unwrap(call.args[0]) - right = self._unwrap(call.args[1]) - if not isinstance(left, self.dax.TableColumnRef) or not isinstance(right, self.dax.TableColumnRef): - raise DaxTranslationError("USERELATIONSHIP expects Table[Column] arguments") - - self._required_models.update({left.table.name, right.table.name}) - return RelationshipOverride( - from_model=left.table.name, - from_column=left.column, - to_model=right.table.name, - to_column=right.column, - join_type=None, - direction=None, - ) - - def _translate_crossfilter(self, call: Any) -> RelationshipOverride | None: - if len(call.args) < 3: - raise DaxTranslationError("CROSSFILTER requires two columns and a direction") - left = self._unwrap(call.args[0]) - right = self._unwrap(call.args[1]) - direction_expr = call.args[2] - - if not isinstance(left, self.dax.TableColumnRef) or not isinstance(right, self.dax.TableColumnRef): - raise DaxTranslationError("CROSSFILTER expects Table[Column] arguments") - - direction = None - if isinstance(direction_expr, self.dax.String): - direction = direction_expr.value - elif isinstance(direction_expr, self.dax.Identifier): - direction = direction_expr.name - - if not direction: - raise DaxTranslationError("CROSSFILTER direction must be a string or identifier") - - normalized = direction.replace(" ", "").upper() - allowed: dict[str, tuple[str | None, str]] = { - "BOTH": ("inner", "Both"), - "NONE": ("left", "None"), - "ONEWAY": (None, "OneWay"), - "ONEWAY_LEFTFILTERSRIGHT": (None, "OneWay_LeftFiltersRight"), - "ONEWAY_RIGHTFILTERSLEFT": (None, "OneWay_RightFiltersLeft"), - } - if normalized not in allowed: - raise DaxTranslationError( - "CROSSFILTER direction must be one of BOTH, NONE, ONEWAY, " - "ONEWAY_LEFTFILTERSRIGHT, ONEWAY_RIGHTFILTERSLEFT" - ) - join_type, canonical_direction = allowed[normalized] - - self._required_models.update({left.table.name, right.table.name}) - return RelationshipOverride( - from_model=left.table.name, - from_column=left.column, - to_model=right.table.name, - to_column=right.column, - join_type=join_type, - direction=canonical_direction, - ) - - def _apply_filter_retentions( - self, filters: list[FilterClause], retentions: list[FilterRetention], remove_all: bool - ) -> list[FilterClause]: - if remove_all: - return [] - if not retentions: - return filters - - remaining = filters - for retention in retentions: - retained: list[FilterClause] = [] - for clause in remaining: - if self._is_removed_by_retention(clause, retention): - continue - retained.append(clause) - remaining = retained - return remaining - - @staticmethod - def _is_removed_by_retention(clause: FilterClause, retention: FilterRetention) -> bool: - matching_columns = [col for col in clause.columns if col.table and col.table.lower() == retention.table.lower()] - if not matching_columns: - return False - if not retention.columns: - return True - for col in matching_columns: - if col.column.lower() not in retention.columns: - return True - return False - - @staticmethod - def _apply_non_keep_clear( - filters: list[FilterClause], remove_all: bool, clear_non_keep: bool - ) -> list[FilterClause]: - if remove_all: - return [] - if not clear_non_keep: - return filters - return [clause for clause in filters if clause.keep] - - def _apply_filter_removals( - self, filters: list[FilterClause], removals: list[FilterRemoval], remove_all: bool - ) -> list[str]: - if remove_all: - return [] - - remaining = [] - for clause in filters: - if self._is_removed(clause, removals): - continue - remaining.append(clause.sql) - return remaining - - @staticmethod - def _is_removed(clause: FilterClause, removals: list[FilterRemoval]) -> bool: - for removal in removals: - for col in clause.columns: - if removal.table and col.table and removal.table.lower() != col.table.lower(): - continue - if removal.column and removal.column.lower() != col.column.lower(): - continue - return True - return False - - def _filter_clause_from_sql(self, sql: str, keep: bool = False) -> FilterClause: - return FilterClause(sql=sql, columns=frozenset(self._columns_from_sql(sql)), keep=keep) - - def _columns_from_sql(self, sql: str) -> set[ColumnRef]: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - except Exception: - return set() - - columns: set[ColumnRef] = set() - for column in parsed.find_all(exp.Column): - table = column.table - if table: - columns.add(ColumnRef(table=table, column=column.name)) - elif self.model_name: - columns.add(ColumnRef(table=self.model_name, column=column.name)) - elif self._base_table: - columns.add(ColumnRef(table=self._base_table, column=column.name)) - return columns - - def _filters_from_table(self, table_expr: Any) -> tuple[list[str], list[RelationshipOverride]]: - table_expr = self._unwrap(table_expr) - table_name = self._table_name_from_expr(table_expr) - if table_name is not None: - self._ensure_table_context(table_name) - return [], [] - - if isinstance(table_expr, self.dax.FunctionCall) and table_expr.name.lower() == "filter": - base_filters, base_overrides = self._filters_from_table(table_expr.args[0]) if table_expr.args else ([], []) - predicate = table_expr.args[1] if len(table_expr.args) > 1 else None - if predicate is None: - return base_filters, base_overrides - clause = self._translate_predicate(predicate, keep=False) - return [*base_filters, clause.sql], base_overrides - - if isinstance(table_expr, self.dax.FunctionCall): - name = table_expr.name.lower() - if name in ("keepfilters", "nonvisual"): - if not table_expr.args: - return [], [] - return self._filters_from_table(table_expr.args[0]) - if name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept"): - return [], [] - if name == "datesbetween": - clause = self._translate_datesbetween_filter(table_expr, keep=False) - if clause is None: - return [], [] - return [clause.sql], [] - if name == "datesinperiod": - clause = self._translate_datesinperiod_filter(table_expr, keep=False) - if clause is None: - return [], [] - return [clause.sql], [] - if name in ("datesytd", "datesmtd", "datesqtd", "dateswtd"): - clause = self._translate_cumulative_period_filter(table_expr, keep=False) - if clause is None: - return [], [] - return [clause.sql], [] - if name in ( - "sameperiodlastyear", - "dateadd", - "parallelperiod", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - ): - clause = self._translate_relative_period_filter(table_expr, keep=False) - if clause is None: - return [], [] - return [clause.sql], [] - if name == "calculatetable": - if not table_expr.args: - return [], [] - base_filters, base_overrides = self._filters_from_table(table_expr.args[0]) - ( - new_filters, - removals, - retentions, - remove_all, - clear_non_keep, - new_overrides, - ) = self._translate_filter_args(table_expr.args[1:]) - inherited = [self._filter_clause_from_sql(sql, keep=False) for sql in base_filters] - combined = self._merge_filter_clauses(inherited, new_filters) - combined = self._apply_non_keep_clear(combined, remove_all, clear_non_keep) - retained = self._apply_filter_retentions(combined, retentions, remove_all) - return self._apply_filter_removals(retained, removals, remove_all), [*base_overrides, *new_overrides] - if name in ( - "selectcolumns", - "addcolumns", - "summarize", - "keepcolumns", - "removecolumns", - "renamecolumns", - "substitutewithindex", - ): - self._translate_table(table_expr) - if table_expr.args: - return self._filters_from_table(table_expr.args[0]) - return [], [] - if name == "topn": - self._translate_table(table_expr) - if len(table_expr.args) > 1: - return self._filters_from_table(table_expr.args[1]) - return [], [] - if name in ("calendar", "generateseries"): - self._translate_table(table_expr) - return [], [] - if name == "union": - self._translate_table(table_expr) - filters: list[str] = [] - overrides: list[RelationshipOverride] = [] - for arg in table_expr.args: - nested_filters, nested_overrides = self._filters_from_table(arg) - filters.extend(nested_filters) - overrides.extend(nested_overrides) - return filters, overrides - if name in ("crossjoin", "naturalinnerjoin", "naturalleftouterjoin"): - self._translate_table(table_expr) - filters: list[str] = [] - overrides: list[RelationshipOverride] = [] - for arg in table_expr.args: - nested_filters, nested_overrides = self._filters_from_table(arg) - filters.extend(nested_filters) - overrides.extend(nested_overrides) - return filters, overrides - if name == "groupby": - self._translate_table(table_expr) - if table_expr.args: - return self._filters_from_table(table_expr.args[0]) - return [], [] - if name == "datatable": - self._translate_table(table_expr) - return [], [] - if name == "relatedtable": - self._translate_table(table_expr) - return [], [] - if name in ("generate", "generateall"): - self._translate_table(table_expr) - filters: list[str] = [] - overrides: list[RelationshipOverride] = [] - for arg in table_expr.args[:2]: - nested_filters, nested_overrides = self._filters_from_table(arg) - filters.extend(nested_filters) - overrides.extend(nested_overrides) - return filters, overrides - if name == "topnskip": - self._translate_table(table_expr) - if len(table_expr.args) > 2: - return self._filters_from_table(table_expr.args[2]) - return [], [] - if name == "topnperlevel": - self._translate_table(table_expr) - table_idx = self._topnperlevel_table_index(table_expr) - if table_idx is None: - return [], [] - return self._filters_from_table(table_expr.args[table_idx]) - if name == "addmissingitems": - self._translate_table(table_expr) - table_arg = self._addmissingitems_table_arg(table_expr) - if table_arg is None: - return [], [] - return self._filters_from_table(table_arg) - if name == "currentgroup": - return [], [] - if name in ("intersect", "except"): - self._translate_table(table_expr) - return [], [] - if name == "summarizecolumns": - self._translate_table(table_expr) - filters: list[str] = [] - overrides: list[RelationshipOverride] = [] - for arg in table_expr.args: - arg = self._unwrap(arg) - if isinstance(arg, self.dax.FunctionCall) and arg.name.lower() in ( - "filter", - "keepfilters", - "nonvisual", - "treatas", - "datesbetween", - "datesinperiod", - "datesytd", - "datesmtd", - "datesqtd", - "dateswtd", - "sameperiodlastyear", - "dateadd", - "parallelperiod", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - ): - candidate = arg - if arg.name.lower() == "keepfilters": - inner = arg.args[0] if arg.args else None - if inner is None: - continue - candidate = self._unwrap(inner) - elif arg.name.lower() == "nonvisual": - inner = arg.args[0] if arg.args else None - if inner is None: - continue - candidate = self._unwrap(inner) - nested_filters, nested_overrides = self._translate_filter_candidate(candidate, keep=False) - filters.extend(clause.sql for clause in nested_filters) - overrides.extend(nested_overrides) - return filters, overrides - if name in ("values", "filters", "distinct"): - self._translate_table(table_expr) - if table_expr.args: - target = self._unwrap(table_expr.args[0]) - if isinstance( - target, - (self.dax.Identifier, self.dax.TableRef, self.dax.FunctionCall, self.dax.TableConstructor), - ): - return self._filters_from_table(target) - return [], [] - return [], [] - - def _translate_predicate(self, expr: Any, keep: bool = False) -> FilterClause: - with self._allow_cross_table_context(): - fragment = self._translate_scalar(expr) - return FilterClause(sql=fragment.sql, columns=frozenset(fragment.columns), keep=keep) - - def _predicate_uses_unknown_columns(self, fragment: _SqlFragment) -> bool: - for column in fragment.columns: - table = column.table - if table is None: - return True - resolved_table = self._resolve_known_table_name(table) - if resolved_table is None: - return True - if not self._table_has_known_column(resolved_table, column.column): - return True - return False - - def _predicate_needs_derived_alias_fallback(self, predicate_expr: Any, predicate_clause: FilterClause) -> bool: - fragment = _SqlFragment(predicate_clause.sql, predicate_clause.columns) - if self._predicate_uses_unknown_columns(fragment): - return True - if not predicate_clause.columns and self._predicate_has_unqualified_identifier(predicate_expr): - return True - return False - - def _translate_alias_backed_filter_predicate( - self, - source_expr: Any, - predicate_expr: Any, - *, - keep: bool, - ) -> FilterClause | None: - alias_env = self._derived_filter_alias_env(source_expr) - if not alias_env: - return None - alias_keys = set(alias_env) - if not self._predicate_references_alias(predicate_expr, alias_keys): - return None - prior_env = dict(self._env) - self._env.update(alias_env) - try: - clause = self._translate_predicate(predicate_expr, keep=keep) - finally: - self._env = prior_env - if clause.columns: - return clause - return None - - def _predicate_has_unqualified_identifier(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if isinstance(expr, (self.dax.Identifier, self.dax.BracketRef)): - return True - if isinstance(expr, (self.dax.TableColumnRef, self.dax.HierarchyRef)): - return False - if isinstance(expr, self.dax.Unary): - return self._predicate_has_unqualified_identifier(expr.expr) - if isinstance(expr, self.dax.Binary): - return self._predicate_has_unqualified_identifier(expr.left) or self._predicate_has_unqualified_identifier( - expr.right - ) - if isinstance(expr, self.dax.VarBlock): - for decl in expr.decls: - if self._predicate_has_unqualified_identifier(decl.expr): - return True - return self._predicate_has_unqualified_identifier(expr.body) - if isinstance(expr, self.dax.FunctionCall): - for arg in expr.args: - if self._predicate_has_unqualified_identifier(arg): - return True - return False - if isinstance(expr, self.dax.Paren): - return self._predicate_has_unqualified_identifier(expr.expr) - return False - - def _predicate_references_alias(self, expr: Any, alias_keys: set[str]) -> bool: - expr = self._unwrap(expr) - if isinstance(expr, (self.dax.Identifier, self.dax.BracketRef)): - return expr.name.lower() in alias_keys - if isinstance(expr, (self.dax.TableColumnRef, self.dax.HierarchyRef)): - return False - if isinstance(expr, self.dax.Unary): - return self._predicate_references_alias(expr.expr, alias_keys) - if isinstance(expr, self.dax.Binary): - return self._predicate_references_alias(expr.left, alias_keys) or self._predicate_references_alias( - expr.right, alias_keys - ) - if isinstance(expr, self.dax.VarBlock): - for decl in expr.decls: - if self._predicate_references_alias(decl.expr, alias_keys): - return True - return self._predicate_references_alias(expr.body, alias_keys) - if isinstance(expr, self.dax.FunctionCall): - for arg in expr.args: - if self._predicate_references_alias(arg, alias_keys): - return True - return False - if isinstance(expr, self.dax.Paren): - return self._predicate_references_alias(expr.expr, alias_keys) - return False - - def _derived_filter_alias_env(self, expr: Any) -> dict[str, _SqlFragment]: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.FunctionCall): - name = expr.name.lower() - if name in ("keepfilters", "nonvisual", "filter", "calculatetable", "distinct") and expr.args: - return self._derived_filter_alias_env(expr.args[0]) - if name == "topn" and len(expr.args) > 1: - return self._derived_filter_alias_env(expr.args[1]) - if name == "topnskip" and len(expr.args) > 2: - return self._derived_filter_alias_env(expr.args[2]) - if name == "topnperlevel": - table_idx = self._topnperlevel_table_index(expr) - if table_idx is not None: - return self._derived_filter_alias_env(expr.args[table_idx]) - return {} - if name == "selectcolumns": - return self._selectcolumns_alias_env(expr) - if name == "addcolumns": - return self._addcolumns_alias_env(expr) - if name == "renamecolumns": - return self._renamecolumns_alias_env(expr) - if name == "row": - return self._row_alias_env(expr) - return {} - - def _selectcolumns_alias_env(self, call: Any) -> dict[str, _SqlFragment]: - if not call.args: - return {} - pairs = call.args[1:] - if len(pairs) % 2 != 0: - return {} - alias_env: dict[str, _SqlFragment] = {} - base_expr = self._unwrap(call.args[0]) - base_table_name = self._table_name_from_expr(base_expr) - if base_table_name is not None: - with self._allow_cross_table_context(): - self._ensure_table_context(base_table_name) - for i in range(0, len(pairs), 2): - alias = self._string_literal_value(pairs[i]) - if alias is None: - return {} - alias_env[alias.lower()] = self._translate_projection_scalar(pairs[i + 1]) - return alias_env - _from_sql, wrapped = self._table_source_from_expr(call.args[0]) - for i in range(0, len(pairs), 2): - alias = self._string_literal_value(pairs[i]) - if alias is None: - return {} - fragment = ( - self._translate_projection_scalar(pairs[i + 1]) if wrapped else self._translate_scalar(pairs[i + 1]) - ) - alias_env[alias.lower()] = fragment - return alias_env - - def _addcolumns_alias_env(self, call: Any) -> dict[str, _SqlFragment]: - if not call.args: - return {} - pairs = call.args[1:] - if len(pairs) % 2 != 0: - return {} - alias_env = self._derived_filter_alias_env(call.args[0]) - base_expr = self._unwrap(call.args[0]) - base_table_name = self._table_name_from_expr(base_expr) - wrapped = False - if base_table_name is not None: - with self._allow_cross_table_context(): - self._ensure_table_context(base_table_name) - else: - _from_sql, wrapped = self._table_source_from_expr(call.args[0]) - for i in range(0, len(pairs), 2): - alias = self._string_literal_value(pairs[i]) - if alias is None: - return {} - fragment = ( - self._translate_projection_scalar(pairs[i + 1]) - if wrapped or base_table_name is not None - else self._translate_scalar(pairs[i + 1]) - ) - alias_env[alias.lower()] = fragment - return alias_env - - def _renamecolumns_alias_env(self, call: Any) -> dict[str, _SqlFragment]: - if len(call.args) < 3: - return {} - alias_env = self._derived_filter_alias_env(call.args[0]) - rename_args = call.args[1:] - if len(rename_args) % 2 != 0: - return {} - for i in range(0, len(rename_args), 2): - source_name = self._table_column_arg_name(rename_args[i], function_name="RENAMECOLUMNS").lower() - target_name = self._table_column_arg_name(rename_args[i + 1], function_name="RENAMECOLUMNS").lower() - fragment = alias_env.pop(source_name, None) - if fragment is None: - try: - fragment = self._translate_scalar(rename_args[i]) - except DaxTranslationError: - return {} - alias_env[target_name] = fragment - return alias_env - - def _row_alias_env(self, call: Any) -> dict[str, _SqlFragment]: - if len(call.args) < 2 or len(call.args) % 2 != 0: - return {} - alias_env: dict[str, _SqlFragment] = {} - for i in range(0, len(call.args), 2): - alias = self._string_literal_value(call.args[i]) - if alias is None: - return {} - try: - fragment = self._translate_projection_scalar(call.args[i + 1]) - except DaxTranslationError: - return {} - alias_env[alias.lower()] = fragment - return alias_env - - @staticmethod - def _is_opaque_filter_predicate(predicate_sql: str) -> bool: - return "AS __filter_table" in predicate_sql - - def _merge_filter_clauses(self, inherited: list[FilterClause], incoming: list[FilterClause]) -> list[FilterClause]: - merged = list(inherited) - for clause in incoming: - if clause.keep or not clause.columns: - merged.append(clause) - continue - - retained: list[FilterClause] = [] - for existing in merged: - if self._filters_overlap(existing, clause): - continue - retained.append(existing) - retained.append(clause) - merged = retained - return merged - - @staticmethod - def _filters_overlap(left: FilterClause, right: FilterClause) -> bool: - for left_col in left.columns: - for right_col in right.columns: - if _columns_match(left_col, right_col): - return True - return False - - def _translate_table(self, expr: Any) -> str: - expr = self._unwrap(expr) - table_name = self._table_name_from_expr(expr) - if table_name is not None: - self._ensure_table_context(table_name) - table_sql = self._table_sql(table_name) - return f"SELECT * FROM {table_sql}" - if isinstance(expr, self.dax.TableConstructor): - return self._translate_table_constructor(expr) - if isinstance(expr, self.dax.FunctionCall): - name = expr.name.lower() - if name in ("keepfilters", "nonvisual"): - return self._translate_table_wrapper(expr) - if name == "filter": - return self._translate_filter_table(expr) - if name == "row": - return self._translate_row_table(expr) - if name == "selectcolumns": - return self._translate_selectcolumns(expr) - if name == "addcolumns": - return self._translate_addcolumns(expr) - if name == "summarizecolumns": - return self._translate_summarizecolumns(expr) - if name == "summarize": - return self._translate_summarize(expr) - if name == "groupby": - return self._translate_groupby(expr) - if name == "topn": - return self._translate_topn(expr) - if name == "topnperlevel": - return self._translate_topnperlevel(expr) - if name == "union": - return self._translate_union_table(expr) - if name == "crossjoin": - return self._translate_crossjoin_table(expr) - if name in ("generate", "generateall"): - return self._translate_generate_table(expr) - if name == "naturalinnerjoin": - return self._translate_natural_inner_join_table(expr) - if name == "naturalleftouterjoin": - return self._translate_natural_left_outer_join_table(expr) - if name == "intersect": - return self._translate_intersect_table(expr) - if name == "except": - return self._translate_except_table(expr) - if name == "topnskip": - return self._translate_topnskip(expr) - if name == "calendar": - return self._translate_calendar_table(expr) - if name == "generateseries": - return self._translate_generateseries_table(expr) - if name == "datatable": - return self._translate_datatable_table(expr) - if name == "relatedtable": - return self._translate_relatedtable_table(expr) - if name == "calculatetable": - return self._translate_calculatetable(expr) - if name == "addmissingitems": - return self._translate_addmissingitems_table(expr) - if name == "treatas": - return self._translate_treatas_table(expr) - if name == "datesbetween": - return self._translate_datesbetween_table(expr) - if name in ("all", "allnoblankrow", "allselected", "allcrossfiltered", "removefilters", "allexcept"): - return self._translate_all_like_table(expr) - if name in ( - "firstdate", - "lastdate", - "startofmonth", - "startofquarter", - "startofyear", - "endofmonth", - "endofquarter", - "endofyear", - ): - return self._translate_date_boundary_table(expr) - if name == "values": - return self._translate_values_table(expr) - if name == "filters": - return self._translate_filters_table(expr) - if name == "distinct": - return self._translate_distinct_table(expr) - if name == "renamecolumns": - return self._translate_renamecolumns_table(expr) - if name == "keepcolumns": - return self._translate_keepcolumns_table(expr) - if name == "removecolumns": - return self._translate_removecolumns_table(expr) - if name == "substitutewithindex": - return self._translate_substitutewithindex_table(expr) - if name in ("selectedmeasure", "selectedmeasurename", "selectedmeasureformatstring", "isselectedmeasure"): - raise DaxTranslationError(f"{expr.name} is only supported in calculation group expressions") - if name == "detailrows": - raise DaxTranslationError("DETAILROWS is only supported in model detail rows expressions") - if isinstance(expr, self.dax.FunctionCall): - raise DaxTranslationError(f"Unsupported table function '{expr.name}'") - if isinstance(expr, self.dax.Identifier): - raise DaxTranslationError(f"Unknown table identifier '{expr.name}'") - raise DaxTranslationError(f"Unsupported table expression type '{type(expr).__name__}'") - - def _translate_table_wrapper(self, call: Any) -> str: - if len(call.args) != 1: - raise DaxTranslationError(f"{call.name} requires exactly one table-expression argument") - inner = self._unwrap(call.args[0]) - if self._table_name_from_expr(inner) is not None: - return self._translate_table(inner) - if isinstance(inner, (self.dax.FunctionCall, self.dax.TableConstructor)): - return self._translate_table(inner) - raise DaxTranslationError(f"{call.name} requires a table-expression argument") - - def _translate_datesbetween_table(self, call: Any) -> str: - if len(call.args) < 3: - raise DaxTranslationError("DATESBETWEEN requires a date column, start date, and end date") - - date_column_expr = self._unwrap(call.args[0]) - table_name: str | None = None - if isinstance(date_column_expr, self.dax.TableColumnRef): - table_name = date_column_expr.table.name - elif isinstance(date_column_expr, self.dax.HierarchyRef): - table_name = date_column_expr.table.name - elif self.model_name: - table_name = self.model_name - elif self._base_table: - table_name = self._base_table - - if table_name is None: - raise DaxTranslationError("DATESBETWEEN requires a table-qualified date column") - - with self._allow_cross_table_context(): - self._ensure_table_context(table_name) - - clause = self._translate_datesbetween_filter(call, keep=False) - tables_in_order = [table_name] - seen_tables = {table_name.lower()} - if clause is not None: - self._append_tables(tables_in_order, seen_tables, clause.columns) - from_clause = self._build_from_clause_for_tables(tables_in_order) - select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(table_name)}.*" - if clause is None: - return f"SELECT {select_sql} FROM {from_clause}" - return f"SELECT {select_sql} FROM {from_clause} WHERE {clause.sql}" - - def _translate_treatas_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("TREATAS requires a table expression and at least one target column") - - target_exprs = [self._unwrap(arg) for arg in call.args[1:]] - with self._allow_cross_table_context(): - target_fragments = [self._translate_scalar(target) for target in target_exprs] - - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - for fragment in target_fragments: - self._append_tables(tables_in_order, seen_tables, fragment.columns) - if not tables_in_order: - raise DaxTranslationError("TREATAS target arguments must reference table columns") - - from_clause = self._build_from_clause_for_tables(tables_in_order) - clause = self._translate_treatas_filter(call, keep=False) - - select_parts: list[str] = [] - for idx, fragment in enumerate(target_fragments): - alias = _column_name_from_expr_sql(fragment.sql) or f"value{idx + 1}" - select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - - return f"SELECT DISTINCT {', '.join(select_parts)} FROM {from_clause} WHERE {clause.sql}" - - def _translate_table_constructor(self, constructor: Any) -> str: - if not constructor.rows: - return "SELECT NULL AS value1 WHERE FALSE" - - width = len(constructor.rows[0]) - if width == 0: - return "SELECT NULL AS value1 WHERE FALSE" - - row_selects: list[str] = [] - for row in constructor.rows: - if len(row) != width: - raise DaxTranslationError("Table constructor rows must have the same number of values") - fragments: list[_SqlFragment] = [] - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - all_values_self_contained = True - for value in row: - try: - fragment = self._translate_scalar(value) - except DaxTranslationError as exc: - if str(exc) != "DAX table expressions must reference a single base table": - raise - with self._allow_cross_table_context(): - fragment = self._translate_scalar(value) - fragments.append(fragment) - self._append_tables(tables_in_order, seen_tables, fragment.columns) - fragment_sql_upper = fragment.sql.strip().upper() - if not (fragment_sql_upper.startswith("SELECT ") or fragment_sql_upper.startswith("(SELECT ")): - all_values_self_contained = False - - cols = [f"{fragment.sql} AS value{idx + 1}" for idx, fragment in enumerate(fragments)] - row_sql = "SELECT " + ", ".join(cols) - if tables_in_order and not all_values_self_contained: - row_sql = f"{row_sql} FROM {self._build_from_clause_for_tables(tables_in_order)}" - row_selects.append(row_sql) - - return " UNION ALL ".join(row_selects) - - def _translate_filter_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("FILTER requires table and predicate") - base_expr = self._unwrap(call.args[0]) - base_table_name = self._table_name_from_expr(base_expr) - if base_table_name is not None: - self._ensure_table_context(base_table_name) - with self._allow_cross_table_context(): - with self._prefer_unqualified_base_table_context(): - predicate = self._translate_scalar(call.args[1]) - - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - self._append_tables(tables_in_order, seen_tables, predicate.columns) - from_clause = self._build_from_clause_for_tables(tables_in_order) - select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" - return f"SELECT {select_sql} FROM {from_clause} WHERE {predicate.sql}" - - from_sql, wrapped = self._table_source_from_expr(call.args[0]) - if wrapped: - with self._prefer_unqualified_base_table_context(): - predicate = self._translate_projection_scalar(call.args[1]) - else: - predicate = self._translate_scalar(call.args[1]) - select_sql = "*" - if wrapped and self._base_table: - tables_in_order = [self._base_table] - seen_tables = {self._base_table.lower()} - self._append_tables(tables_in_order, seen_tables, predicate.columns) - if len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) - select_sql = "t.*" - return f"SELECT {select_sql} FROM {from_sql} WHERE {predicate.sql}" - - def _translate_row_table(self, call: Any) -> str: - if len(call.args) < 2 or len(call.args) % 2 != 0: - raise DaxTranslationError("ROW requires name/expression pairs") - - prior_base_table = self._base_table - select_parts: list[str] = [] - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - all_values_self_contained = True - for idx in range(0, len(call.args), 2): - alias = self._string_literal_value(call.args[idx]) - if alias is None: - raise DaxTranslationError("ROW name must be a string") - value_expr = call.args[idx + 1] - try: - fragment = self._translate_scalar(value_expr) - except DaxTranslationError as exc: - if str(exc) != "DAX table expressions must reference a single base table": - raise - prefer_unqualified_base = self.model_name is None and not self._allow_cross_table - qualifier_ctx = ( - self._prefer_unqualified_base_table_context() if prefer_unqualified_base else nullcontext() - ) - with self._allow_cross_table_context(): - with qualifier_ctx: - fragment = self._translate_scalar(value_expr) - select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - self._append_tables(tables_in_order, seen_tables, fragment.columns) - fragment_sql_upper = fragment.sql.strip().upper() - if not (fragment_sql_upper.startswith("SELECT ") or fragment_sql_upper.startswith("(SELECT ")): - all_values_self_contained = False - - select_sql = ", ".join(select_parts) - if not tables_in_order: - return f"SELECT {select_sql}" - if all_values_self_contained: - return f"SELECT {select_sql}" - if prior_base_table is not None and all(table.lower() == prior_base_table.lower() for table in tables_in_order): - return f"SELECT {select_sql}" - from_clause = self._build_from_clause_for_tables(tables_in_order) - return f"SELECT {select_sql} FROM {from_clause}" - - def _translate_union_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("UNION requires at least two table arguments") - - parts: list[str] = [] - for idx, arg in enumerate(call.args): - base_sql = self._translate_table_with_isolated_base_context(arg, preserve_result_base=idx == 0) - parts.append(f"SELECT * FROM ({base_sql}) AS t{idx}") - return " UNION ALL ".join(parts) - - def _translate_crossjoin_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("CROSSJOIN requires at least two table arguments") - - from_parts: list[str] = [] - for idx, arg in enumerate(call.args): - base_sql = self._translate_table_with_isolated_base_context(arg, preserve_result_base=idx == 0) - from_parts.append(f"({base_sql}) AS t{idx}") - return f"SELECT * FROM {' CROSS JOIN '.join(from_parts)}" - - def _translate_natural_inner_join_table(self, call: Any) -> str: - if len(call.args) != 2: - raise DaxTranslationError("NATURALINNERJOIN requires exactly two table arguments") - - left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) - right_sql = self._translate_table_with_isolated_base_context(call.args[1]) - return f"SELECT * FROM ({left_sql}) AS t0 NATURAL INNER JOIN ({right_sql}) AS t1" - - def _translate_natural_left_outer_join_table(self, call: Any) -> str: - if len(call.args) != 2: - raise DaxTranslationError("NATURALLEFTOUTERJOIN requires exactly two table arguments") - - left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) - right_sql = self._translate_table_with_isolated_base_context(call.args[1]) - return f"SELECT * FROM ({left_sql}) AS t0 NATURAL LEFT JOIN ({right_sql}) AS t1" - - def _translate_intersect_table(self, call: Any) -> str: - if len(call.args) != 2: - raise DaxTranslationError("INTERSECT requires exactly two table arguments") - - left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) - right_sql = self._translate_table_with_isolated_base_context(call.args[1]) - return f"SELECT * FROM ({left_sql}) AS t0 INTERSECT ALL SELECT * FROM ({right_sql}) AS t1" - - def _translate_except_table(self, call: Any) -> str: - if len(call.args) != 2: - raise DaxTranslationError("EXCEPT requires exactly two table arguments") - - left_sql = self._translate_table_with_isolated_base_context(call.args[0], preserve_result_base=True) - right_sql = self._translate_table_with_isolated_base_context(call.args[1]) - return f"SELECT * FROM ({left_sql}) AS t0 EXCEPT ALL SELECT * FROM ({right_sql}) AS t1" - - def _translate_substitutewithindex_table(self, call: Any) -> str: - if len(call.args) < 4: - raise DaxTranslationError( - "SUBSTITUTEWITHINDEX requires a source table, index column name, index table, and order-by expression" - ) - - left_expr = self._unwrap(call.args[0]) - left_sql = self._translate_table_with_isolated_base_context(left_expr, preserve_result_base=True) - - index_name = self._string_literal_value(call.args[1]) - if index_name is None: - raise DaxTranslationError("SUBSTITUTEWITHINDEX index column name must be a string") - - trailing_args = [self._unwrap(arg) for arg in call.args[2:]] - table_positions = [idx for idx, arg in enumerate(trailing_args) if self._is_table_expression_node(arg)] - if len(table_positions) != 1: - raise DaxTranslationError("SUBSTITUTEWITHINDEX requires exactly one index table argument") - - index_expr = trailing_args[table_positions[0]] - order_args = [arg for idx, arg in enumerate(trailing_args) if idx != table_positions[0]] - if not order_args: - raise DaxTranslationError("SUBSTITUTEWITHINDEX requires at least one order-by expression") - - index_sql = self._translate_table_with_isolated_base_context(index_expr) - left_cols = self._infer_table_expr_output_columns(left_expr, left_sql) - index_cols = self._infer_table_expr_output_columns(index_expr, index_sql) - if not left_cols or not index_cols: - raise DaxTranslationError("SUBSTITUTEWITHINDEX requires inferable source and index table columns") - left_col_counts = self._infer_table_expr_output_column_counts(left_expr, left_sql) - index_col_counts = self._infer_table_expr_output_column_counts(index_expr, index_sql) - - left_map = {name.lower(): name for name in sorted(left_cols, key=str.lower)} - index_map = {name.lower(): name for name in sorted(index_cols, key=str.lower)} - common_keys = sorted(set(left_map) & set(index_map)) - if not common_keys: - raise DaxTranslationError( - "SUBSTITUTEWITHINDEX requires at least one common column between source and index tables" - ) - for key in common_keys: - if left_col_counts.get(key, 0) > 1: - source_name = left_map.get(key, key) - raise DaxTranslationError( - f"SUBSTITUTEWITHINDEX source table has ambiguous common column '{source_name}'" - ) - if index_col_counts.get(key, 0) > 1: - source_name = index_map.get(key, key) - raise DaxTranslationError( - f"SUBSTITUTEWITHINDEX index table has ambiguous common column '{source_name}'" - ) - - index_tables = self._collect_table_references(index_expr) - order_by_parts = self._parse_substitutewithindex_order_by_parts( - order_args, - index_tables, - index_cols, - index_col_counts, - ) - - common_index_cols = [index_map[key] for key in common_keys] - group_cols_sql = ", ".join(f"i1.{self._quote_identifier(name)}" for name in common_index_cols) - ranked_alias = "__substitutewithindex_rank" - ranked_sql = ( - f"SELECT i0.*, DENSE_RANK() OVER (ORDER BY {', '.join(order_by_parts)}) AS {self._quote_identifier(ranked_alias)} " - f"FROM ({index_sql}) AS i0" - ) - mapping_sql = ( - f"SELECT {group_cols_sql}, MIN(i1.{self._quote_identifier(ranked_alias)}) AS {self._quote_identifier(ranked_alias)} " - f"FROM ({ranked_sql}) AS i1 GROUP BY {group_cols_sql}" - ) - - join_predicates = [ - f"l.{self._quote_identifier(left_map[key])} IS NOT DISTINCT FROM i.{self._quote_identifier(index_map[key])}" - for key in common_keys - ] - left_keep = [left_map[key] for key in sorted(left_map) if key not in common_keys] - projections = [f"l.{self._quote_identifier(name)}" for name in left_keep] - projections.append(f"i.{self._quote_identifier(ranked_alias)} AS {self._quote_identifier(index_name)}") - return ( - f"SELECT {', '.join(projections)} FROM ({left_sql}) AS l " - f"LEFT JOIN ({mapping_sql}) AS i ON {' AND '.join(join_predicates)}" - ) - - def _is_table_expression_node(self, expr: Any) -> bool: - if self._table_name_from_expr(expr) is not None: - return True - if isinstance(expr, self.dax.FunctionCall): - return self._is_table_function_name(expr.name.lower()) - if isinstance(expr, self.dax.TableConstructor): - return True - if isinstance(expr, self.dax.Identifier): - if self._is_known_measure_identifier(expr.name): - return False - return self._table_exists(expr.name) - return False - - def _parse_substitutewithindex_order_by_parts( - self, - order_args: list[Any], - source_tables: set[str], - source_columns: set[str], - source_column_counts: dict[str, int] | None = None, - ) -> list[str]: - parts: list[str] = [] - source_tables_lower = {table.lower() for table in source_tables} - source_columns_lower = {column.lower() for column in source_columns} - source_column_counts = source_column_counts or {} - idx = 0 - while idx < len(order_args): - expr = order_args[idx] - direction = "ASC" - if idx + 1 < len(order_args): - direction_ident = self._identifier_literal_value(order_args[idx + 1]) - if direction_ident is not None and direction_ident.upper() in ("ASC", "DESC"): - direction = direction_ident.upper() - idx += 2 - else: - idx += 1 - else: - idx += 1 - - with self._allow_cross_table_context(): - fragment = self._translate_scalar(expr) - for ref in fragment.columns: - if ref.table is not None and ref.table.lower() not in source_tables_lower: - raise DaxTranslationError( - "SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument" - ) - if ref.column.lower() not in source_columns_lower: - raise DaxTranslationError( - "SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument" - ) - if source_column_counts.get(ref.column.lower(), 0) > 1: - raise DaxTranslationError( - f"SUBSTITUTEWITHINDEX ORDER BY column '{ref.column}' is ambiguous in index table expression" - ) - try: - rewritten = _rewrite_expr_for_alias( - fragment.sql, - "i0", - source_tables=source_tables, - source_columns=source_columns, - allow_fallback=False, - strict_source_resolution=True, - ) - except DaxTranslationError as exc: - raise DaxTranslationError( - "SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument" - ) from exc - parts.append(f"{rewritten} {direction}") - return parts - - def _translate_calendar_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("CALENDAR requires start date and end date") - with self._allow_cross_table_context(): - start_fragment = self._translate_scalar(call.args[0]) - end_fragment = self._translate_scalar(call.args[1]) - start = self._scalar_fragment_sql_with_from(start_fragment) - end = self._scalar_fragment_sql_with_from(end_fragment) - return ( - "SELECT date_value AS Date FROM generate_series(" - f"CAST({start} AS DATE), CAST({end} AS DATE), INTERVAL '1 day'" - ") AS gs(date_value)" - ) - - def _translate_generateseries_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("GENERATESERIES requires start and end arguments") - with self._allow_cross_table_context(): - start_fragment = self._translate_scalar(call.args[0]) - end_fragment = self._translate_scalar(call.args[1]) - step_fragment = ( - self._translate_scalar(call.args[2]) if len(call.args) > 2 else _SqlFragment("1", frozenset()) - ) - start = self._scalar_fragment_sql_with_from(start_fragment) - end = self._scalar_fragment_sql_with_from(end_fragment) - step = self._scalar_fragment_sql_with_from(step_fragment) - return f"SELECT value FROM generate_series({start}, {end}, {step}) AS gs(value)" - - def _translate_selectcolumns(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("SELECTCOLUMNS requires a table and column pairs") - pairs = call.args[1:] - if len(pairs) % 2 != 0: - raise DaxTranslationError("SELECTCOLUMNS requires name/expression pairs") - - base_expr = self._unwrap(call.args[0]) - base_table_name = self._table_name_from_expr(base_expr) - select_parts = [] - if base_table_name is not None: - self._ensure_table_context(base_table_name) - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - for i in range(0, len(pairs), 2): - name_expr = pairs[i] - value_expr = pairs[i + 1] - alias = self._string_literal_value(name_expr) - if alias is None: - raise DaxTranslationError("SELECTCOLUMNS name must be a string") - fragment = self._translate_projection_scalar(value_expr) - select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - self._append_tables(tables_in_order, seen_tables, fragment.columns) - from_sql = self._build_from_clause_for_tables(tables_in_order) - else: - from_sql, wrapped = self._table_source_from_expr(call.args[0]) - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - if wrapped and self._base_table: - tables_in_order = [self._base_table] - seen_tables = {self._base_table.lower()} - for i in range(0, len(pairs), 2): - name_expr = pairs[i] - value_expr = pairs[i + 1] - alias = self._string_literal_value(name_expr) - if alias is None: - raise DaxTranslationError("SELECTCOLUMNS name must be a string") - fragment = ( - self._translate_projection_scalar(value_expr) if wrapped else self._translate_scalar(value_expr) - ) - select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - if wrapped and self._base_table: - self._append_tables(tables_in_order, seen_tables, fragment.columns) - if wrapped and self._base_table and len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) - - select_sql = ", ".join(select_parts) - return f"SELECT {select_sql} FROM {from_sql}" - - def _translate_addcolumns(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("ADDCOLUMNS requires a table and column pairs") - pairs = call.args[1:] - if len(pairs) % 2 != 0: - raise DaxTranslationError("ADDCOLUMNS requires name/expression pairs") - - base_expr = self._unwrap(call.args[0]) - base_table_name = self._table_name_from_expr(base_expr) - select_parts: list[str] = [] - if base_table_name is not None: - self._ensure_table_context(base_table_name) - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - for i in range(0, len(pairs), 2): - alias = self._string_literal_value(pairs[i]) - if alias is None: - raise DaxTranslationError("ADDCOLUMNS name must be a string") - fragment = self._translate_projection_scalar(pairs[i + 1]) - select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - self._append_tables(tables_in_order, seen_tables, fragment.columns) - from_sql = self._build_from_clause_for_tables(tables_in_order) - if len(tables_in_order) == 1: - select_parts.insert(0, "*") - else: - select_parts.insert(0, f"{self._table_sql(base_table_name)}.*") - else: - from_sql, wrapped = self._table_source_from_expr(call.args[0]) - select_parts = ["t.*" if wrapped else "*"] - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - if wrapped and self._base_table: - tables_in_order = [self._base_table] - seen_tables = {self._base_table.lower()} - for i in range(0, len(pairs), 2): - alias = self._string_literal_value(pairs[i]) - if alias is None: - raise DaxTranslationError("ADDCOLUMNS name must be a string") - fragment = ( - self._translate_projection_scalar(pairs[i + 1]) if wrapped else self._translate_scalar(pairs[i + 1]) - ) - select_parts.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - if wrapped and self._base_table: - self._append_tables(tables_in_order, seen_tables, fragment.columns) - if wrapped and self._base_table and len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) - - select_sql = ", ".join(select_parts) - return f"SELECT {select_sql} FROM {from_sql}" - - def _translate_summarizecolumns(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("SUMMARIZECOLUMNS requires arguments") - - group_by: list[str] = [] - measures: list[str] = [] - filters: list[str] = [] - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - group_by_columns: set[ColumnRef] = set() - filter_columns: set[ColumnRef] = set() - - args = list(call.args) - idx = 0 - with self._allow_cross_table_context(): - while idx < len(args): - arg = self._unwrap(args[idx]) - if isinstance(arg, self.dax.String): - break - group_by_args = self._extract_group_by_args(arg) - if group_by_args is not None: - for group_arg in group_by_args: - fragment = self._translate_scalar(group_arg) - group_by.append(fragment.sql) - group_by_columns.update(fragment.columns) - self._append_tables(tables_in_order, seen_tables, fragment.columns) - idx += 1 - continue - if isinstance(arg, self.dax.FunctionCall) and self._is_filter_table_candidate(arg): - filter_arg = arg - if arg.name.lower() in ("keepfilters", "nonvisual"): - inner = arg.args[0] if arg.args else None - if inner is None: - idx += 1 - continue - filter_arg = self._unwrap(inner) - clauses, _overrides = self._translate_filter_candidate(filter_arg, keep=False) - for clause in clauses: - filters.append(clause.sql) - filter_columns.update(clause.columns) - self._append_tables(tables_in_order, seen_tables, clause.columns) - idx += 1 - continue - if isinstance(arg, self.dax.FunctionCall): - try: - self._translate_table(arg) - except DaxTranslationError as exc: - if not _is_unsupported_table_expression_error(exc): - raise - else: - nested_filters, _overrides = self._filters_from_table(arg) - for clause_sql in nested_filters: - clause = self._filter_clause_from_sql(clause_sql, keep=False) - filters.append(clause.sql) - filter_columns.update(clause.columns) - self._append_tables(tables_in_order, seen_tables, clause.columns) - idx += 1 - continue - table_name = self._table_name_from_expr(arg) - if table_name is not None: - self._ensure_table_context(table_name) - if table_name.lower() not in seen_tables: - tables_in_order.append(table_name) - seen_tables.add(table_name.lower()) - idx += 1 - continue - raise DaxTranslationError("Unsupported SUMMARIZECOLUMNS argument") - - remaining = args[idx:] - if len(remaining) % 2 != 0: - raise DaxTranslationError("SUMMARIZECOLUMNS requires name/expression pairs") - - with self._measure_eval_context(group_by_columns, filter_columns): - for i in range(0, len(remaining), 2): - alias = self._string_literal_value(remaining[i]) - if alias is None: - raise DaxTranslationError("SUMMARIZECOLUMNS name must be a string") - fragment = self._translate_scalar(remaining[i + 1]) - measures.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - self._append_tables(tables_in_order, seen_tables, fragment.columns) - - if not group_by and not measures: - raise DaxTranslationError("SUMMARIZECOLUMNS produced no columns") - - from_clause = self._build_from_clause_for_tables(tables_in_order) - select_parts = group_by + measures - select_sql = ", ".join(select_parts) - group_by_sql = "" - if group_by: - group_by_sql = f" GROUP BY {', '.join(group_by)}" - where_sql = "" - if filters: - where_sql = f" WHERE {' AND '.join(filters)}" - return f"SELECT {select_sql} FROM {from_clause}{where_sql}{group_by_sql}" - - def _translate_summarize(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("SUMMARIZE requires a table and at least one group-by column") - - base_expr = call.args[0] - from_sql, wrapped = self._table_source_from_expr(base_expr) - base_table = self._base_table.lower() if self._base_table else None - - group_by: list[str] = [] - measures: list[str] = [] - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - group_by_columns: set[ColumnRef] = set() - - if base_table: - tables_in_order.append(self._base_table or "") - seen_tables.add(base_table) - - args = list(call.args[1:]) - idx = 0 - with self._allow_cross_table_context(): - qualifier_ctx = self._prefer_unqualified_base_table_context() if wrapped else nullcontext() - with qualifier_ctx: - while idx < len(args): - arg = self._unwrap(args[idx]) - if isinstance(arg, self.dax.String): - break - group_by_args = self._extract_group_by_args(arg) - if group_by_args is None and wrapped and isinstance(arg, self.dax.Identifier): - group_by_args = [arg] - if group_by_args is not None: - for group_arg in group_by_args: - if ( - wrapped - and base_table is None - and isinstance(group_arg, (self.dax.BracketRef, self.dax.Identifier)) - ): - fragment = _SqlFragment(self._quote_identifier(group_arg.name), frozenset()) - else: - fragment = self._translate_scalar(group_arg) - group_by.append(fragment.sql) - group_by_columns.update(fragment.columns) - self._append_tables(tables_in_order, seen_tables, fragment.columns) - idx += 1 - continue - raise DaxTranslationError("Unsupported SUMMARIZE group-by argument") - - remaining = args[idx:] - if len(remaining) % 2 != 0: - raise DaxTranslationError("SUMMARIZE requires name/expression pairs after group-by columns") - - with self._measure_eval_context(group_by_columns, set()): - for i in range(0, len(remaining), 2): - alias = self._string_literal_value(remaining[i]) - if alias is None: - raise DaxTranslationError("SUMMARIZE name must be a string") - fragment = self._translate_scalar(remaining[i + 1]) - measures.append(f"{fragment.sql} AS {self._quote_identifier(alias)}") - self._append_tables(tables_in_order, seen_tables, fragment.columns) - - if not group_by and not measures: - raise DaxTranslationError("SUMMARIZE produced no columns") - - if wrapped: - if base_table: - from_clause = self._build_from_clause_for_wrapped_base( - from_sql, - self._base_table or "", - tables_in_order, - ) - else: - from_clause = from_sql - else: - from_clause = self._build_from_clause_for_tables(tables_in_order) - select_parts = group_by + measures - select_sql = ", ".join(select_parts) - group_by_sql = "" - if group_by: - group_by_sql = f" GROUP BY {', '.join(group_by)}" - return f"SELECT {select_sql} FROM {from_clause}{group_by_sql}" - - def _build_from_clause_for_wrapped_base(self, from_sql: str, base_table: str, tables_in_order: list[str]) -> str: - if not tables_in_order: - return from_sql - - base_key = base_table.lower() - from_parts = [from_sql] - joined_tables = {base_key} - joined_order = [base_table] - - for table in tables_in_order: - table_key = table.lower() - if table_key in joined_tables: - continue - path = self._find_relationship_path_from_joined(joined_order, table) - if path is None: - if self._allow_unrelated_table_cross_join: - self._append_unrelated_cross_join_warning(base_table, table) - from_parts.append(f"CROSS JOIN {self._table_sql(table)}") - joined_tables.add(table_key) - joined_order.append(table) - continue - raise DaxTranslationError(f"No relationship path between {base_table} and {table}") - - for from_table, to_table, from_col, to_col in path: - to_key = to_table.lower() - if to_key in joined_tables: - continue - left_table = "t" if from_table.lower() == base_key else self._table_sql(from_table) - right_table = self._table_sql(to_table) - from_col_sql = self._quote_identifier(from_col) - to_col_sql = self._quote_identifier(to_col) - from_parts.append( - f"LEFT JOIN {right_table} ON {left_table}.{from_col_sql} = {right_table}.{to_col_sql}" - ) - joined_tables.add(to_key) - joined_order.append(to_table) - - return " ".join(from_parts) - - def _extract_group_by_args(self, expr: Any) -> list[Any] | None: - expr = self._unwrap(expr) - if isinstance(expr, (self.dax.TableColumnRef, self.dax.HierarchyRef, self.dax.BracketRef)): - return [expr] - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() in ( - "rollup", - "rollupgroup", - "rollupissubtotal", - "rollupaddissubtotal", - ): - group_by_args: list[Any] = [] - if expr.name.lower() == "rollupaddissubtotal": - idx = 0 - while idx < len(expr.args): - current = self._unwrap(expr.args[idx]) - if isinstance(current, self.dax.String): - idx += 1 - continue - nested = self._extract_group_by_args(current) - if nested is None: - raise DaxTranslationError( - f"{expr.name} only supports column and hierarchy references in this context" - ) - group_by_args.extend(nested) - idx += 1 - if idx < len(expr.args) and isinstance(self._unwrap(expr.args[idx]), self.dax.String): - idx += 1 - else: - for arg in expr.args: - nested = self._extract_group_by_args(arg) - if nested is None: - raise DaxTranslationError( - f"{expr.name} only supports column and hierarchy references in this context" - ) - group_by_args.extend(nested) - if not group_by_args: - raise DaxTranslationError(f"{expr.name} requires at least one group-by argument") - return group_by_args - return None - - def _translate_topn(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("TOPN requires count and table") - count_sql = self._topn_numeric_arg_sql(call.args[0], function_name="TOPN", arg_name="count") - table_expr = self._unwrap(call.args[1]) - base_table_name = self._table_name_from_expr(table_expr) - if base_table_name is not None: - self._ensure_table_context(base_table_name) - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - order_by_parts, order_columns = self._parse_order_by_parts_with_columns( - call.args[2:], - projection_safe=True, - ) - self._append_tables(tables_in_order, seen_tables, order_columns) - from_sql = self._build_from_clause_for_tables(tables_in_order) - select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" - order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" - return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql}" - - from_sql, wrapped = self._table_source_from_expr(call.args[1]) - order_by_parts, order_columns = self._parse_order_by_parts_with_columns( - call.args[2:], - projection_safe=wrapped, - ) - select_sql = "*" - if wrapped and self._base_table: - tables_in_order = [self._base_table] - seen_tables = {self._base_table.lower()} - self._append_tables(tables_in_order, seen_tables, order_columns) - if len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) - select_sql = "t.*" - order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" - return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql}" - - def _translate_topnperlevel(self, call: Any) -> str: - if len(call.args) < 3: - raise DaxTranslationError("TOPNPERLEVEL requires count, group-by column(s), and table") - count_sql = self._topn_numeric_arg_sql(call.args[0], function_name="TOPNPERLEVEL", arg_name="count") - table_idx = self._topnperlevel_table_index(call) - if table_idx is None: - raise DaxTranslationError("TOPNPERLEVEL requires a table argument") - table_expr = self._unwrap(call.args[table_idx]) - base_table_name = self._table_name_from_expr(table_expr) - wrapped = False - from_sql: str | None = None - if base_table_name is None: - from_sql, wrapped = self._table_source_from_expr(call.args[table_idx]) - if wrapped and self._base_table: - base_table_name = self._base_table - if base_table_name is not None: - self._ensure_table_context(base_table_name) - group_by_parts, group_by_columns = self._topnperlevel_group_by_parts( - call, - table_idx, - projection_safe=wrapped, - ) - if not group_by_parts: - raise DaxTranslationError("TOPNPERLEVEL requires at least one group-by column") - - if base_table_name is not None and not wrapped: - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - self._append_tables(tables_in_order, seen_tables, group_by_columns) - order_by_parts, order_by_columns = self._parse_order_by_parts_with_columns( - call.args[table_idx + 1 :], - projection_safe=True, - ) - self._append_tables(tables_in_order, seen_tables, order_by_columns) - from_sql = self._build_from_clause_for_tables(tables_in_order) - row_projection = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" - if not order_by_parts: - order_by_parts = list(group_by_parts) - rank_alias = "__topnperlevel_rank" - ranked_sql = ( - f"SELECT {row_projection}, RANK() OVER (PARTITION BY {', '.join(group_by_parts)} " - f"ORDER BY {', '.join(order_by_parts)}) AS {rank_alias} FROM {from_sql}" - ) - return f"SELECT * EXCLUDE ({rank_alias}) FROM ({ranked_sql}) AS q WHERE {rank_alias} <= {count_sql}" - - if wrapped and base_table_name and from_sql is not None: - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - self._append_tables(tables_in_order, seen_tables, group_by_columns) - order_by_parts, order_by_columns = self._parse_order_by_parts_with_columns( - call.args[table_idx + 1 :], - projection_safe=True, - ) - self._append_tables(tables_in_order, seen_tables, order_by_columns) - row_projection = "*" - if len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_wrapped_base(from_sql, base_table_name, tables_in_order) - row_projection = "t.*" - if not order_by_parts: - order_by_parts = list(group_by_parts) - rank_alias = "__topnperlevel_rank" - ranked_sql = ( - f"SELECT {row_projection}, RANK() OVER (PARTITION BY {', '.join(group_by_parts)} " - f"ORDER BY {', '.join(order_by_parts)}) AS {rank_alias} FROM {from_sql}" - ) - return f"SELECT * EXCLUDE ({rank_alias}) FROM ({ranked_sql}) AS q WHERE {rank_alias} <= {count_sql}" - - if from_sql is None: - from_sql, _wrapped = self._table_source_from_expr(call.args[table_idx]) - order_by_parts, _order_columns = self._parse_order_by_parts_with_columns( - call.args[table_idx + 1 :], - projection_safe=wrapped, - ) - if not order_by_parts: - order_by_parts = list(group_by_parts) - rank_alias = "__topnperlevel_rank" - ranked_sql = ( - f"SELECT *, RANK() OVER (PARTITION BY {', '.join(group_by_parts)} " - f"ORDER BY {', '.join(order_by_parts)}) AS {rank_alias} FROM {from_sql}" - ) - return f"SELECT * EXCLUDE ({rank_alias}) FROM ({ranked_sql}) AS q WHERE {rank_alias} <= {count_sql}" - - def _translate_topnskip(self, call: Any) -> str: - if len(call.args) < 3: - raise DaxTranslationError("TOPNSKIP requires count, skip, and table") - count_sql = self._topn_numeric_arg_sql(call.args[0], function_name="TOPNSKIP", arg_name="count") - skip_sql = self._topn_numeric_arg_sql(call.args[1], function_name="TOPNSKIP", arg_name="skip") - table_expr = self._unwrap(call.args[2]) - base_table_name = self._table_name_from_expr(table_expr) - if base_table_name is not None: - self._ensure_table_context(base_table_name) - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - order_by_parts, order_columns = self._parse_order_by_parts_with_columns( - call.args[3:], - projection_safe=True, - ) - self._append_tables(tables_in_order, seen_tables, order_columns) - from_sql = self._build_from_clause_for_tables(tables_in_order) - select_sql = "*" if len(tables_in_order) == 1 else f"{self._table_sql(base_table_name)}.*" - order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" - return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql} OFFSET {skip_sql}" - - from_sql, wrapped = self._table_source_from_expr(call.args[2]) - order_by_parts, order_columns = self._parse_order_by_parts_with_columns( - call.args[3:], - projection_safe=wrapped, - ) - select_sql = "*" - if wrapped and self._base_table: - tables_in_order = [self._base_table] - seen_tables = {self._base_table.lower()} - self._append_tables(tables_in_order, seen_tables, order_columns) - if len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) - select_sql = "t.*" - order_by_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" - return f"SELECT {select_sql} FROM {from_sql}{order_by_sql} LIMIT {count_sql} OFFSET {skip_sql}" - - def _topnperlevel_table_index(self, call: Any) -> int | None: - for idx, arg in enumerate(call.args[1:], start=1): - candidate = self._unwrap(arg) - if self._table_name_from_expr(candidate) is not None: - return idx - if isinstance(candidate, (self.dax.FunctionCall, self.dax.TableConstructor)): - return idx - return None - - def _topnperlevel_group_by_parts( - self, - call: Any, - table_idx: int, - *, - projection_safe: bool = False, - ) -> tuple[list[str], set[ColumnRef]]: - group_parts: list[str] = [] - columns: set[ColumnRef] = set() - context = nullcontext() if projection_safe else self._allow_cross_table_context() - with context: - for raw_arg in call.args[1:table_idx]: - group_args = self._extract_group_by_args(raw_arg) - if group_args is None: - raise DaxTranslationError("TOPNPERLEVEL group-by arguments must be column or hierarchy references") - for group_arg in group_args: - fragment = ( - self._translate_projection_scalar(group_arg) - if projection_safe - else self._translate_scalar(group_arg) - ) - group_parts.append(fragment.sql) - columns.update(fragment.columns) - return group_parts, columns - - def _parse_order_by_parts(self, args: list[Any]) -> list[str]: - order_by_parts, _columns = self._parse_order_by_parts_with_columns(args) - return order_by_parts - - def _parse_order_by_parts_with_columns( - self, - args: list[Any], - *, - projection_safe: bool = False, - ) -> tuple[list[str], set[ColumnRef]]: - order_by_parts: list[str] = [] - columns: set[ColumnRef] = set() - idx = 0 - while idx < len(args): - expr = args[idx] - direction = None - if idx + 1 < len(args): - direction = self._identifier_literal_value(args[idx + 1]) - if direction is not None and direction.upper() in ("ASC", "DESC"): - idx += 2 - else: - direction = None - idx += 1 - else: - idx += 1 - fragment = self._translate_projection_scalar(expr) if projection_safe else self._translate_scalar(expr) - expr_sql = fragment.sql - columns.update(fragment.columns) - if direction: - order_by_parts.append(f"{expr_sql} {direction.upper()}") - else: - order_by_parts.append(expr_sql) - return order_by_parts, columns - - def _translate_groupby(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("GROUPBY requires a table and at least one group-by column") - return self._translate_summarize(call) - - def _translate_generate_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError(f"{call.name} requires at least two table arguments") - left_sql = self._translate_table(call.args[0]) - try: - right_sql = self._translate_table(call.args[1]) - except DaxTranslationError as exc: - if str(exc) != "DAX table expressions must reference a single base table": - raise - with self._allow_cross_table_context(): - right_sql = self._translate_table(call.args[1]) - left_tables = self._collect_table_references(call.args[0]) - left_columns = _query_output_columns(left_sql) - if not left_columns and left_tables and _query_uses_star_projection(left_sql): - left_columns = self._known_columns_for_tables(left_tables) - ambiguous_left_columns = self._ambiguous_columns_for_tables(left_tables) - left_column_aliases, ambiguous_left_lineage_aliases = _query_output_lineage_aliases(left_sql) - right_source_tables = _query_source_table_names(right_sql) - right_local_columns = self._known_columns_for_tables(right_source_tables) - if left_tables: - right_sql = _rewrite_expr_for_alias( - right_sql, - "l", - source_tables=left_tables, - source_columns=left_columns, - source_column_aliases=left_column_aliases, - ambiguous_source_aliases=ambiguous_left_lineage_aliases, - local_columns=right_local_columns, - ambiguous_source_columns=ambiguous_left_columns, - allow_fallback=False, - strict_source_resolution=True, - ) - if call.name.lower() == "generateall": - return f"SELECT * FROM ({left_sql}) AS l LEFT JOIN LATERAL ({right_sql}) AS r ON TRUE" - return f"SELECT * FROM ({left_sql}) AS l CROSS JOIN LATERAL ({right_sql}) AS r" - - def _translate_table_with_isolated_base_context(self, expr: Any, *, preserve_result_base: bool = False) -> str: - prior_base_table = self._base_table - self._base_table = None - try: - sql = self._translate_table(expr) - translated_base_table = self._base_table - finally: - self._base_table = prior_base_table - if preserve_result_base and prior_base_table is None and translated_base_table is not None: - self._base_table = translated_base_table - return sql - - def _translate_addmissingitems_table(self, call: Any) -> str: - table_idx = self._addmissingitems_table_index(call) - if table_idx is None: - raise DaxTranslationError("ADDMISSINGITEMS requires a table expression argument") - table_arg = call.args[table_idx] - base_sql = self._translate_table(table_arg) - - group_specs: list[Any] = [] - domain_filter_clauses: list[str] = [] - domain_filter_columns: set[ColumnRef] = set() - other_args = [arg for idx, arg in enumerate(call.args) if idx != table_idx] - for candidate_arg in other_args: - if self._extract_group_by_args(candidate_arg) is not None: - group_specs.append(candidate_arg) - continue - with self._allow_cross_table_context(): - nested_filters, _overrides = self._filters_from_table(candidate_arg) - domain_filter_clauses.extend(nested_filters) - for clause_sql in nested_filters: - domain_filter_columns.update(self._columns_from_sql(clause_sql)) - - if not group_specs: - return base_sql - - group_parts: list[_SqlFragment] = [] - seen_group_sql: set[str] = set() - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - with self._allow_cross_table_context(): - for raw_arg in group_specs: - group_args = self._extract_group_by_args(raw_arg) - if group_args is None: - raise DaxTranslationError( - "ADDMISSINGITEMS group-by arguments must be column or hierarchy references" - ) - for group_arg in group_args: - fragment = self._translate_scalar(group_arg) - group_key = fragment.sql.strip().lower() - if group_key in seen_group_sql: - continue - seen_group_sql.add(group_key) - group_parts.append(fragment) - self._append_tables(tables_in_order, seen_tables, fragment.columns) - - if not group_parts or not tables_in_order: - return base_sql - - if domain_filter_columns: - self._append_tables(tables_in_order, seen_tables, domain_filter_columns) - - domain_selects: list[str] = [] - projections: list[str] = [] - projection_names: list[str] = [] - join_predicates: list[str] = [] - base_output_cols = _query_output_columns(base_sql) - base_output_keys = {name.lower() for name in base_output_cols if name and name != "*"} - for idx, fragment in enumerate(group_parts): - key_alias = f"__addmissingitems_k{idx}" - output_name = _column_name_from_expr_sql(fragment.sql) or f"value{idx + 1}" - domain_selects.append(f"{fragment.sql} AS {key_alias}") - projections.append(f"d.{key_alias} AS {self._quote_identifier(output_name)}") - projection_names.append(output_name) - if output_name.lower() in base_output_keys: - base_expr = _rewrite_expr_for_alias(fragment.sql, "b") - join_predicates.append(f"{base_expr} IS NOT DISTINCT FROM d.{key_alias}") - - domain_from = self._build_from_clause_for_tables(tables_in_order) - domain_where = f" WHERE {' AND '.join(domain_filter_clauses)}" if domain_filter_clauses else "" - domain_sql = f"SELECT DISTINCT {', '.join(domain_selects)} FROM {domain_from}{domain_where}" - on_sql = " AND ".join(join_predicates) if join_predicates else "TRUE" - projected_name_keys = {name.lower() for name in projection_names} - duplicate_base_cols = sorted( - (name for name in base_output_cols if name and name != "*" and name.lower() in projected_name_keys), - key=str.lower, - ) - base_select = "b.*" - if duplicate_base_cols: - excluded_cols = ", ".join(self._quote_identifier(name) for name in duplicate_base_cols) - base_select = f"b.* EXCLUDE ({excluded_cols})" - select_sql = ", ".join([*projections, base_select]) - return f"SELECT {select_sql} FROM ({domain_sql}) AS d LEFT JOIN ({base_sql}) AS b ON {on_sql}" - - def _translate_datatable_table(self, call: Any) -> str: - if len(call.args) < 3: - raise DaxTranslationError("DATATABLE requires column definitions and row values") - rows_expr = self._unwrap(call.args[-1]) - if not isinstance(rows_expr, self.dax.TableConstructor): - raise DaxTranslationError("DATATABLE requires a table-constructor row argument") - - column_args = list(call.args[:-1]) - if len(column_args) % 2 != 0: - raise DaxTranslationError("DATATABLE requires name/datatype column pairs") - - columns: list[str] = [] - for idx in range(0, len(column_args), 2): - col_name = self._string_literal_value(column_args[idx]) - if col_name is None: - raise DaxTranslationError("DATATABLE column names must be strings") - columns.append(col_name) - - if not columns: - raise DaxTranslationError("DATATABLE requires at least one column") - - row_values_sql: list[str] = [] - for row in rows_expr.rows: - normalized_row = self._normalize_datatable_row(row, len(columns)) - if normalized_row is None: - raise DaxTranslationError("DATATABLE row width must match column definition count") - fragments = [self._translate_scalar(value) for value in normalized_row] - row_values_sql.append("(" + ", ".join(fragment.sql for fragment in fragments) + ")") - - aliased_columns = ", ".join(self._quote_identifier(name) for name in columns) - if not row_values_sql: - nulls = ", ".join(f"CAST(NULL AS VARCHAR) AS {self._quote_identifier(name)}" for name in columns) - return f"SELECT {nulls} WHERE 1 = 0" - return f"SELECT * FROM (VALUES {', '.join(row_values_sql)}) AS t({aliased_columns})" - - def _translate_relatedtable_table(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("RELATEDTABLE requires a table argument") - target = self._unwrap(call.args[0]) - table_name = self._table_name_from_expr(target) - if table_name is None: - raise DaxTranslationError("RELATEDTABLE requires a table reference argument") - with self._allow_cross_table_context(): - self._ensure_table_context(table_name) - return f"SELECT * FROM {self._table_sql(table_name)}" - - def _normalize_datatable_row(self, row: list[Any], width: int) -> list[Any] | None: - if len(row) == width: - return row - if len(row) == 1: - nested = self._unwrap(row[0]) - if isinstance(nested, self.dax.TableConstructor): - flattened: list[Any] = [] - for nested_row in nested.rows: - if len(nested_row) != 1: - return None - flattened.append(nested_row[0]) - if len(flattened) == width: - return flattened - return None - - def _addmissingitems_table_arg(self, call: Any) -> Any | None: - table_idx = self._addmissingitems_table_index(call) - if table_idx is not None: - return call.args[table_idx] - return None - - def _addmissingitems_table_index(self, call: Any) -> int | None: - group_tables = self._addmissingitems_group_tables(call) - non_group_candidates: list[tuple[int, Any]] = [] - for idx, arg in enumerate(call.args): - candidate = self._unwrap(arg) - if self._extract_group_by_args(candidate) is not None: - continue - if isinstance(candidate, (self.dax.TableRef, self.dax.Identifier, self.dax.TableConstructor)): - non_group_candidates.append((idx, candidate)) - continue - if isinstance(candidate, self.dax.FunctionCall): - non_group_candidates.append((idx, candidate)) - - if not non_group_candidates: - return None - - for idx, candidate in non_group_candidates: - core_candidate = self._addmissingitems_table_core_expr(candidate) - if isinstance(core_candidate, self.dax.FunctionCall) and core_candidate.name.lower() == "summarizecolumns": - return idx - for idx, candidate in non_group_candidates: - core_candidate = self._addmissingitems_table_core_expr(candidate) - if self._is_addmissingitems_strong_preferred_table_core(core_candidate): - return idx - for idx, candidate in non_group_candidates: - core_candidate = self._addmissingitems_table_core_expr(candidate) - if self._is_addmissingitems_preferred_table_core( - core_candidate - ) and self._addmissingitems_core_has_non_group_table(core_candidate, group_tables): - return idx - for idx, candidate in non_group_candidates: - core_candidate = self._addmissingitems_table_core_expr(candidate) - if self._is_addmissingitems_likely_main_table_core(core_candidate, group_tables): - return idx - for idx, candidate in non_group_candidates: - core_candidate = self._addmissingitems_table_core_expr(candidate) - if self._is_addmissingitems_preferred_table_core(core_candidate): - return idx - - non_filter_candidates = [ - idx for idx, candidate in non_group_candidates if not self._is_filter_table_candidate(candidate) - ] - if non_filter_candidates: - return non_filter_candidates[0] - - for idx, candidate in non_group_candidates: - if not self._is_explicit_filter_wrapper(candidate): - return idx - - return non_group_candidates[0][0] - - def _addmissingitems_group_tables(self, call: Any) -> set[str]: - tables: set[str] = set() - for arg in call.args: - candidate = self._unwrap(arg) - group_args = self._extract_group_by_args(candidate) - if group_args is None: - continue - for group_arg in group_args: - tables.update(self._collect_table_references(group_arg)) - return {table.lower() for table in tables} - - def _addmissingitems_core_has_non_group_table(self, expr: Any, group_tables: set[str]) -> bool: - tables = {table.lower() for table in self._collect_table_references(expr)} - if not tables: - return False - return any(table not in group_tables for table in tables) - - def _addmissingitems_table_core_expr(self, expr: Any) -> Any: - expr = self._unwrap(expr) - if not isinstance(expr, self.dax.FunctionCall): - return expr - name = expr.name.lower() - if ( - name in ("keepfilters", "nonvisual", "filter", "renamecolumns", "keepcolumns", "removecolumns") - and expr.args - ): - return self._addmissingitems_table_core_expr(expr.args[0]) - if name == "calculatetable" and expr.args: - return self._addmissingitems_table_core_expr(expr.args[0]) - return expr - - def _is_addmissingitems_preferred_table_core(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if not isinstance(expr, self.dax.FunctionCall): - return False - name = expr.name.lower() - return name in ( - "summarize", - "groupby", - "row", - "selectcolumns", - "addcolumns", - "renamecolumns", - "keepcolumns", - "removecolumns", - "topn", - "topnskip", - "topnperlevel", - "union", - "crossjoin", - "naturalinnerjoin", - "naturalleftouterjoin", - "intersect", - "except", - "generate", - "generateall", - "calendar", - "generateseries", - "datatable", - ) - - def _is_addmissingitems_strong_preferred_table_core(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if not isinstance(expr, self.dax.FunctionCall): - return False - name = expr.name.lower() - return name in ( - "summarize", - "groupby", - "row", - "selectcolumns", - "addcolumns", - "renamecolumns", - "keepcolumns", - "removecolumns", - "topn", - "topnskip", - "topnperlevel", - "generate", - "generateall", - ) - - def _is_addmissingitems_likely_main_table_core(self, expr: Any, group_tables: set[str]) -> bool: - expr = self._unwrap(expr) - table_name = self._table_name_from_expr(expr) - if table_name is None: - return self._addmissingitems_core_has_non_group_table(expr, group_tables) - return table_name.lower() not in group_tables - - def _is_filter_table_candidate(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if not isinstance(expr, self.dax.FunctionCall): - return False - name = expr.name.lower() - return name in ( - "filter", - "calculatetable", - "keepfilters", - "nonvisual", - "treatas", - "datesbetween", - "datesinperiod", - "datesytd", - "datesmtd", - "datesqtd", - "dateswtd", - "sameperiodlastyear", - "dateadd", - "parallelperiod", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - "all", - "allnoblankrow", - "allselected", - "allcrossfiltered", - "removefilters", - "allexcept", - "values", - "filters", - "distinct", - "renamecolumns", - "keepcolumns", - "removecolumns", - "substitutewithindex", - "union", - "crossjoin", - "naturalinnerjoin", - "naturalleftouterjoin", - "intersect", - "except", - ) - - def _is_table_filter_candidate_expr(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.TableRef): - return True - if isinstance(expr, self.dax.Identifier): - if self._is_known_measure_identifier(expr.name): - return False - return self._table_exists(expr.name) - return False - - def _is_known_measure_identifier(self, name: str) -> bool: - if self.model_name: - model_key = self.model_name.lower() - for table, measure_names in self.measure_names_by_table.items(): - if table.lower() != model_key: - continue - if name in measure_names: - return True - lower = name.lower() - return any(known.lower() == lower for known in measure_names) - return False - return self._resolve_measure_reference(name) is not None - - def _table_exists(self, name: str) -> bool: - key = name.lower() - for table_name in self.column_sql_by_table: - if table_name.lower() == key: - return True - for table_name in self.measure_names_by_table: - if table_name.lower() == key: - return True - return False - - def _is_explicit_filter_wrapper(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if not isinstance(expr, self.dax.FunctionCall): - return False - name = expr.name.lower() - return name in ( - "filter", - "calculatetable", - "keepfilters", - "nonvisual", - "treatas", - "datesbetween", - "datesinperiod", - "datesytd", - "datesmtd", - "datesqtd", - "dateswtd", - "sameperiodlastyear", - "dateadd", - "parallelperiod", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - "all", - "allnoblankrow", - "allselected", - "allcrossfiltered", - "removefilters", - "allexcept", - ) - - def _collect_table_references(self, expr: Any) -> set[str]: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.TableRef): - return {expr.table.name} - if isinstance(expr, self.dax.TableColumnRef): - return {expr.table.name} - if isinstance(expr, self.dax.HierarchyRef): - return {expr.table.name} - if isinstance(expr, self.dax.Identifier): - return {expr.name} - if isinstance(expr, self.dax.FunctionCall): - tables: set[str] = set() - for arg in expr.args: - tables.update(self._collect_table_references(arg)) - return tables - if isinstance(expr, self.dax.TableConstructor): - tables: set[str] = set() - for row in expr.rows: - for value in row: - tables.update(self._collect_table_references(value)) - return tables - if isinstance(expr, self.dax.Unary): - return self._collect_table_references(expr.expr) - if isinstance(expr, self.dax.Binary): - return self._collect_table_references(expr.left) | self._collect_table_references(expr.right) - if isinstance(expr, self.dax.VarBlock): - tables: set[str] = set() - for decl in expr.decls: - tables.update(self._collect_table_references(decl.expr)) - tables.update(self._collect_table_references(expr.body)) - return tables - if isinstance(expr, self.dax.Paren): - return self._collect_table_references(expr.expr) - return set() - - def _known_columns_for_tables(self, tables: set[str]) -> set[str]: - columns: set[str] = set() - for table in tables: - column_map = self._column_map_for_table(table) - for source_name, mapped in column_map.items(): - columns.add(source_name) - mapped_name = _identifier_name_from_sql(mapped) - if mapped_name: - columns.add(mapped_name) - return columns - - def _column_map_for_table(self, table: str) -> dict[str, str]: - if table in self.column_sql_by_table: - return self.column_sql_by_table[table] - table_key = table.lower() - for table_name, column_map in self.column_sql_by_table.items(): - if table_name.lower() == table_key: - return column_map - return {} - - def _known_table_references(self, expr: Any) -> set[str]: - known: set[str] = set() - for table in self._collect_table_references(expr): - resolved = self._resolve_known_table_name(table) - if resolved is not None: - known.add(resolved) - return known - - def _resolve_known_table_name(self, table: str) -> str | None: - if table in self.column_sql_by_table or table in self.measure_names_by_table: - return table - key = table.lower() - for table_name in self.column_sql_by_table: - if table_name.lower() == key: - return table_name - for table_name in self.measure_names_by_table: - if table_name.lower() == key: - return table_name - return None - - def _table_has_known_column(self, table: str, column: str) -> bool: - column_key = column.lower() - for source_name, mapped in self._column_map_for_table(table).items(): - if source_name.lower() == column_key: - return True - mapped_name = _identifier_name_from_sql(mapped) - if mapped_name and mapped_name.lower() == column_key: - return True - return False - - def _infer_table_expr_output_columns(self, expr: Any, sql: str) -> set[str]: - shape_columns = self._infer_table_expr_output_columns_by_shape(expr) - columns = _query_output_columns(sql) - uses_star = _query_uses_star_projection(sql) - if columns and not uses_star: - return columns - tables = self._collect_table_references(expr) - if tables and not columns and uses_star: - return self._known_columns_for_tables(tables) - if columns and uses_star: - star_qualifiers = _query_star_projection_qualifiers(sql) - qualified_star_tables = {name for name in star_qualifiers if name and self._table_exists(name)} - if qualified_star_tables: - return columns | self._known_columns_for_tables(qualified_star_tables) - if None in star_qualifiers and len(tables) == 1: - return columns | self._known_columns_for_tables(tables) - if shape_columns: - return shape_columns - return set() - if shape_columns: - return shape_columns - return set() - - def _infer_table_expr_output_columns_by_shape(self, expr: Any) -> set[str]: - counts = self._infer_table_expr_output_column_counts_by_shape(expr) - names: set[str] = set() - for key in counts: - names.add(key) - return names - - def _infer_table_expr_output_column_counts(self, expr: Any, sql: str) -> dict[str, int]: - counts = self._infer_table_expr_output_column_counts_by_shape(expr) - sql_counts = _query_output_column_name_counts(sql) - if not counts: - return sql_counts - if not sql_counts: - return counts - merged = dict(sql_counts) - for key, value in counts.items(): - merged[key] = max(merged.get(key, 0), value) - return merged - - def _infer_table_expr_output_column_counts_by_shape(self, expr: Any) -> dict[str, int]: - expr = self._unwrap(expr) - - table_name = self._table_name_from_expr(expr) - if table_name is not None: - counts: dict[str, int] = {} - for column_name in self._known_columns_for_tables({table_name}): - counts[column_name.lower()] = 1 - return counts - - if isinstance(expr, self.dax.FunctionCall): - name = expr.name.lower() - passthrough_first_arg = { - "filter", - "calculatetable", - "keepfilters", - "nonvisual", - "distinct", - "all", - "allnoblankrow", - "allselected", - "allcrossfiltered", - "removefilters", - } - if name in passthrough_first_arg and expr.args: - return self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) - if name == "topn" and len(expr.args) >= 2: - return self._infer_table_expr_output_column_counts_by_shape(expr.args[1]) - if name == "topnskip" and len(expr.args) >= 3: - return self._infer_table_expr_output_column_counts_by_shape(expr.args[2]) - if name == "topnperlevel": - table_idx = self._topnperlevel_table_index(expr) - if table_idx is not None and table_idx < len(expr.args): - return self._infer_table_expr_output_column_counts_by_shape(expr.args[table_idx]) - return {} - if name == "selectcolumns": - if len(expr.args) < 1: - return {} - pairs = expr.args[1:] - if len(pairs) % 2 != 0: - return {} - counts: dict[str, int] = {} - for i in range(0, len(pairs), 2): - alias = self._string_literal_value(pairs[i]) - if alias is None: - return {} - alias_key = alias.lower() - counts[alias_key] = counts.get(alias_key, 0) + 1 - return counts - if name == "addcolumns": - if not expr.args: - return {} - base = self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) - if not base: - return {} - pairs = expr.args[1:] - if len(pairs) % 2 != 0: - return {} - out = dict(base) - for i in range(0, len(pairs), 2): - alias = self._string_literal_value(pairs[i]) - if alias is None: - return {} - alias_key = alias.lower() - out[alias_key] = out.get(alias_key, 0) + 1 - return out - if name == "keepcolumns": - if len(expr.args) < 2: - return {} - keep_counts: dict[str, int] = {} - for raw_arg in expr.args[1:]: - try: - keep_name = self._table_column_arg_name(raw_arg, function_name="KEEPCOLUMNS") - except DaxTranslationError: - return {} - keep_counts.setdefault(keep_name.lower(), 1) - return keep_counts - if name == "removecolumns": - if len(expr.args) < 2: - return {} - base = self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) - if not base: - return {} - remove_keys: set[str] = set() - for raw_arg in expr.args[1:]: - try: - remove_name = self._table_column_arg_name(raw_arg, function_name="REMOVECOLUMNS") - except DaxTranslationError: - return {} - remove_keys.add(remove_name.lower()) - return {key: value for key, value in base.items() if key not in remove_keys} - if name == "renamecolumns": - if len(expr.args) < 3: - return {} - pairs = expr.args[1:] - if len(pairs) % 2 != 0: - return {} - base = self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) - if not base: - return {} - out = dict(base) - for i in range(0, len(pairs), 2): - try: - source = self._table_column_arg_name(pairs[i], function_name="RENAMECOLUMNS") - target = self._table_column_arg_name(pairs[i + 1], function_name="RENAMECOLUMNS") - except DaxTranslationError: - return {} - source_key = source.lower() - target_key = target.lower() - source_count = out.pop(source_key, None) - if source_count is None: - return {} - out[target_key] = out.get(target_key, 0) + source_count - return out - if name in ("union", "intersect", "except") and expr.args: - return self._infer_table_expr_output_column_counts_by_shape(expr.args[0]) - if name in ( - "crossjoin", - "naturalinnerjoin", - "naturallefterouterjoin", - "naturallefterjoin", - "naturalleftouterjoin", - "generate", - "generateall", - ): - counts: dict[str, int] = {} - for arg in expr.args: - nested = self._infer_table_expr_output_column_counts_by_shape(arg) - for key, value in nested.items(): - counts[key] = counts.get(key, 0) + value - return counts - if name == "row": - if len(expr.args) < 2 or len(expr.args) % 2 != 0: - return {} - counts: dict[str, int] = {} - for i in range(0, len(expr.args), 2): - alias = self._string_literal_value(expr.args[i]) - if alias is None: - return {} - alias_key = alias.lower() - counts[alias_key] = counts.get(alias_key, 0) + 1 - return counts - if name == "datatable": - if len(expr.args) < 3: - return {} - column_args = list(expr.args[:-1]) - if len(column_args) % 2 != 0: - return {} - counts: dict[str, int] = {} - for i in range(0, len(column_args), 2): - col_name = self._string_literal_value(column_args[i]) - if col_name is None: - return {} - col_key = col_name.lower() - counts[col_key] = counts.get(col_key, 0) + 1 - return counts - - return {} - - def _ambiguous_columns_for_tables(self, tables: set[str]) -> set[str]: - counts: dict[str, int] = {} - for table in tables: - column_map = self.column_sql_by_table.get(table, {}) - table_columns: set[str] = set() - for source_name, mapped in column_map.items(): - table_columns.add(source_name.lower()) - mapped_name = _identifier_name_from_sql(mapped) - if mapped_name: - table_columns.add(mapped_name.lower()) - for column_name in table_columns: - counts[column_name] = counts.get(column_name, 0) + 1 - return {column_name for column_name, count in counts.items() if count > 1} - - def _translate_values_table(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("VALUES requires an argument") - target = self._unwrap(call.args[0]) - - table_name = self._table_name_from_expr(target) - if table_name is not None: - self._ensure_table_context(table_name) - return f"SELECT DISTINCT * FROM {self._table_sql(table_name)}" - - if isinstance(target, (self.dax.FunctionCall, self.dax.TableConstructor)): - base_sql = self._translate_table(target) - return f"SELECT DISTINCT * FROM ({base_sql}) AS t" - - with self._allow_cross_table_context(): - fragment = self._translate_scalar(target) - - referenced_tables: dict[str, str] = {} - for column in fragment.columns: - if not column.table: - continue - key = column.table.lower() - referenced_tables.setdefault(key, column.table) - - tables: list[str] = [] - if self._base_table and self._base_table.lower() in referenced_tables: - tables.append(referenced_tables.pop(self._base_table.lower())) - for _, table_name in sorted(referenced_tables.items(), key=lambda item: item[0]): - tables.append(table_name) - - if tables: - if len(tables) == 1: - table_sql = self._table_sql(tables[0]) - else: - table_sql = self._build_from_clause_for_tables(tables) - else: - table_sql = self._default_table_sql() - - return f"SELECT DISTINCT {fragment.sql} FROM {table_sql}" - - def _translate_filters_table(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("FILTERS requires an argument") - values_call = self.dax.FunctionCall(name="VALUES", args=[call.args[0]]) - return self._translate_values_table(values_call) - - def _translate_all_like_table(self, call: Any) -> str: - name = call.name.lower() - if name == "allexcept": - if not call.args: - raise DaxTranslationError("ALLEXCEPT requires at least a table argument") - target = self._unwrap(call.args[0]) - table_name = self._table_name_from_expr(target) - if table_name is None: - raise DaxTranslationError("ALLEXCEPT first argument must be a table reference") - self._ensure_table_context(table_name) - return f"SELECT * FROM {self._table_sql(table_name)}" - - if not call.args: - table_name = self.model_name or self._base_table - if table_name is None: - raise DaxTranslationError(f"{call.name} requires a table argument without a table context") - self._ensure_table_context(table_name) - return f"SELECT * FROM {self._table_sql(table_name)}" - - target = self._unwrap(call.args[0]) - table_name = self._table_name_from_expr(target) - if table_name is not None: - self._ensure_table_context(table_name) - return f"SELECT * FROM {self._table_sql(table_name)}" - - if isinstance(target, (self.dax.FunctionCall, self.dax.TableConstructor)): - base_sql = self._translate_table(target) - return f"SELECT DISTINCT * FROM ({base_sql}) AS t" - - values_call = self.dax.FunctionCall(name="VALUES", args=[target]) - return self._translate_values_table(values_call) - - def _translate_date_boundary_table(self, call: Any) -> str: - fragment = self._translate_function_scalar(call) - tables_in_order: list[str] = [] - seen_tables: set[str] = set() - self._append_tables(tables_in_order, seen_tables, fragment.columns) - from_clause = ( - self._build_from_clause_for_tables(tables_in_order) if tables_in_order else self._default_table_sql() - ) - return f"SELECT {fragment.sql} AS value1 FROM {from_clause}" - - def _translate_distinct_table(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("DISTINCT requires an argument") - target = self._unwrap(call.args[0]) - if isinstance( - target, (self.dax.Identifier, self.dax.TableRef, self.dax.FunctionCall, self.dax.TableConstructor) - ): - base_sql = self._translate_table(target) - return f"SELECT DISTINCT * FROM ({base_sql}) AS t" - return self._translate_values_table(call) - - def _translate_renamecolumns_table(self, call: Any) -> str: - if len(call.args) < 3: - raise DaxTranslationError("RENAMECOLUMNS requires a table expression and at least one old/new column pair") - - rename_args = call.args[1:] - if len(rename_args) % 2 != 0: - raise DaxTranslationError("RENAMECOLUMNS requires old/new column argument pairs") - - base_expr = call.args[0] - base_sql = self._translate_table(base_expr) - available_columns = self._infer_table_expr_output_columns(base_expr, base_sql) - input_column_counts = self._infer_table_expr_output_column_counts(base_expr, base_sql) - ambiguous_input_columns = {key for key, count in input_column_counts.items() if count > 1} - input_tables = self._known_table_references(base_expr) - available_lookup = {name.lower(): name for name in available_columns} - rename_parts: list[str] = [] - seen_sources: set[str] = set() - seen_targets: set[str] = set() - for idx in range(0, len(rename_args), 2): - source_spec = self._table_column_arg_spec(rename_args[idx], function_name="RENAMECOLUMNS") - source_name = source_spec.name - target_name = self._table_column_arg_name(rename_args[idx + 1], function_name="RENAMECOLUMNS") - source_key = source_name.lower() - target_key = target_name.lower() - self._validate_table_qualified_column_arg( - source_spec, - function_name="RENAMECOLUMNS", - input_tables=input_tables, - ) - if source_key in ambiguous_input_columns: - raise DaxTranslationError( - f"RENAMECOLUMNS source column '{source_name}' is ambiguous in input table expression" - ) - if available_lookup and source_key not in available_lookup: - raise DaxTranslationError( - f"RENAMECOLUMNS source column '{source_name}' is not present in input table expression" - ) - if source_key in seen_sources: - raise DaxTranslationError("RENAMECOLUMNS source columns must be unique") - if target_key in seen_targets: - raise DaxTranslationError("RENAMECOLUMNS target column names must be unique") - seen_sources.add(source_key) - seen_targets.add(target_key) - resolved_source = available_lookup.get(source_key, source_name) - rename_parts.append(f"{self._quote_identifier(resolved_source)} AS {self._quote_identifier(target_name)}") - - if not rename_parts: - raise DaxTranslationError("RENAMECOLUMNS requires at least one valid old/new column pair") - - return f"SELECT * RENAME ({', '.join(rename_parts)}) FROM ({base_sql}) AS t" - - def _translate_keepcolumns_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("KEEPCOLUMNS requires a table expression and at least one column argument") - - base_expr = call.args[0] - base_sql = self._translate_table(base_expr) - available_columns = self._infer_table_expr_output_columns(base_expr, base_sql) - input_column_counts = self._infer_table_expr_output_column_counts(base_expr, base_sql) - ambiguous_input_columns = {key for key, count in input_column_counts.items() if count > 1} - input_tables = self._known_table_references(base_expr) - available_lookup = {name.lower(): name for name in available_columns} - keep_names: list[str] = [] - seen: set[str] = set() - for raw_arg in call.args[1:]: - spec = self._table_column_arg_spec(raw_arg, function_name="KEEPCOLUMNS") - column_name = spec.name - key = column_name.lower() - self._validate_table_qualified_column_arg(spec, function_name="KEEPCOLUMNS", input_tables=input_tables) - if key in ambiguous_input_columns: - raise DaxTranslationError(f"KEEPCOLUMNS column '{column_name}' is ambiguous in input table expression") - if available_lookup and key not in available_lookup: - raise DaxTranslationError( - f"KEEPCOLUMNS column '{column_name}' is not present in input table expression" - ) - if key in seen: - continue - seen.add(key) - keep_names.append(available_lookup.get(key, column_name)) - - if not keep_names: - raise DaxTranslationError("KEEPCOLUMNS requires at least one valid column argument") - - projections = ", ".join(f"t.{self._quote_identifier(name)}" for name in keep_names) - return f"SELECT {projections} FROM ({base_sql}) AS t" - - def _translate_removecolumns_table(self, call: Any) -> str: - if len(call.args) < 2: - raise DaxTranslationError("REMOVECOLUMNS requires a table expression and at least one column argument") - - base_expr = call.args[0] - base_sql = self._translate_table(base_expr) - available_columns = self._infer_table_expr_output_columns(base_expr, base_sql) - input_column_counts = self._infer_table_expr_output_column_counts(base_expr, base_sql) - ambiguous_input_columns = {key for key, count in input_column_counts.items() if count > 1} - input_tables = self._known_table_references(base_expr) - available_lookup = {name.lower(): name for name in available_columns} - exclude_names: list[str] = [] - seen: set[str] = set() - for raw_arg in call.args[1:]: - spec = self._table_column_arg_spec(raw_arg, function_name="REMOVECOLUMNS") - column_name = spec.name - key = column_name.lower() - self._validate_table_qualified_column_arg(spec, function_name="REMOVECOLUMNS", input_tables=input_tables) - if key in ambiguous_input_columns: - raise DaxTranslationError( - f"REMOVECOLUMNS column '{column_name}' is ambiguous in input table expression" - ) - if available_lookup and key not in available_lookup: - raise DaxTranslationError( - f"REMOVECOLUMNS column '{column_name}' is not present in input table expression" - ) - if key in seen: - continue - seen.add(key) - exclude_names.append(available_lookup.get(key, column_name)) - - if not exclude_names: - raise DaxTranslationError("REMOVECOLUMNS requires at least one valid column argument") - - excluded = ", ".join(self._quote_identifier(name) for name in exclude_names) - return f"SELECT * EXCLUDE ({excluded}) FROM ({base_sql}) AS t" - - def _table_column_arg_spec(self, expr: Any, *, function_name: str) -> TableColumnArg: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.TableColumnRef): - return TableColumnArg(name=expr.column, table=expr.table.name) - if isinstance(expr, self.dax.HierarchyRef): - return TableColumnArg( - name=expr.levels[-1] if expr.levels else expr.column, - table=expr.table.name, - ) - if isinstance(expr, self.dax.String): - return TableColumnArg(name=expr.value) - if isinstance(expr, self.dax.Identifier): - return TableColumnArg(name=expr.name) - if isinstance(expr, self.dax.BracketRef): - return TableColumnArg(name=expr.name) - raise DaxTranslationError(f"{function_name} column arguments must be column references or names") - - def _table_column_arg_name(self, expr: Any, *, function_name: str) -> str: - return self._table_column_arg_spec(expr, function_name=function_name).name - - def _validate_table_qualified_column_arg( - self, - arg: TableColumnArg, - *, - function_name: str, - input_tables: set[str], - ) -> None: - if arg.table is None: - return - - table_name = self._resolve_known_table_name(arg.table) - if table_name is None: - raise DaxTranslationError(f"{function_name} column '{arg.name}' references unknown table '{arg.table}'") - if table_name not in input_tables: - raise DaxTranslationError( - f"{function_name} column '{arg.name}' references table '{arg.table}' not present in input table expression" - ) - if not self._table_has_known_column(table_name, arg.name): - raise DaxTranslationError( - f"{function_name} column '{arg.name}' is not present on referenced table '{arg.table}'" - ) - - def _translate_calculatetable(self, call: Any) -> str: - if not call.args: - raise DaxTranslationError("CALCULATETABLE requires a table expression") - from_sql, wrapped, inherited_filters = self._flatten_calculatetable_source(call.args[0]) - if wrapped: - with self._prefer_unqualified_base_table_context(): - ( - new_filters, - removals, - retentions, - remove_all, - clear_non_keep, - _overrides, - ) = self._translate_filter_args(call.args[1:]) - else: - ( - new_filters, - removals, - retentions, - remove_all, - clear_non_keep, - _overrides, - ) = self._translate_filter_args(call.args[1:]) - inherited = [self._filter_clause_from_sql(sql, keep=True) for sql in inherited_filters] - combined = self._merge_filter_clauses(inherited, new_filters) - combined = self._apply_non_keep_clear(combined, remove_all, clear_non_keep) - retained = self._apply_filter_retentions(combined, retentions, remove_all) - predicates = self._apply_filter_removals(retained, removals, remove_all) - select_sql = "*" - if wrapped and self._base_table: - tables_in_order = [self._base_table] - seen_tables = {self._base_table.lower()} - for predicate in predicates: - if self._is_opaque_filter_predicate(predicate): - continue - self._append_tables(tables_in_order, seen_tables, self._columns_from_sql(predicate)) - if len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_wrapped_base(from_sql, self._base_table, tables_in_order) - select_sql = "t.*" - elif not wrapped: - base_table_name = self._base_table or self.model_name - if base_table_name: - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - for predicate in predicates: - if self._is_opaque_filter_predicate(predicate): - continue - self._append_tables(tables_in_order, seen_tables, self._columns_from_sql(predicate)) - if len(tables_in_order) > 1: - from_sql = self._build_from_clause_for_tables(tables_in_order) - select_sql = f"{self._table_sql(base_table_name)}.*" - if not predicates: - return f"SELECT {select_sql} FROM {from_sql}" - return f"SELECT {select_sql} FROM {from_sql} WHERE {' AND '.join(predicates)}" - - def _flatten_calculatetable_source(self, expr: Any) -> tuple[str, bool, list[str]]: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "calculatetable": - if not expr.args: - return self._default_table_sql(), False, [] - - from_sql, wrapped, inherited_filters = self._flatten_calculatetable_source(expr.args[0]) - if wrapped: - with self._prefer_unqualified_base_table_context(): - ( - new_filters, - removals, - retentions, - remove_all, - clear_non_keep, - _overrides, - ) = self._translate_filter_args(expr.args[1:]) - else: - ( - new_filters, - removals, - retentions, - remove_all, - clear_non_keep, - _overrides, - ) = self._translate_filter_args(expr.args[1:]) - - inherited = [self._filter_clause_from_sql(sql, keep=True) for sql in inherited_filters] - combined = self._merge_filter_clauses(inherited, new_filters) - combined = self._apply_non_keep_clear(combined, remove_all, clear_non_keep) - retained = self._apply_filter_retentions(combined, retentions, remove_all) - predicates = self._apply_filter_removals(retained, removals, remove_all) - return from_sql, wrapped, predicates - - from_sql, wrapped = self._table_source_from_expr(expr) - return from_sql, wrapped, [] - - def _table_source_from_expr(self, expr: Any) -> tuple[str, bool]: - expr = self._unwrap(expr) - table_name = self._table_name_from_expr(expr) - if table_name is not None: - self._ensure_table_context(table_name) - return self._table_sql(table_name), False - base_sql = self._translate_table(expr) - return f"({base_sql}) AS t", True - - def _translate_scalar(self, expr: Any) -> _SqlFragment: - expr = self._unwrap(expr) - - if isinstance(expr, self.dax.Number): - return _SqlFragment(expr.value, frozenset()) - if isinstance(expr, self.dax.String): - return _SqlFragment(self._quote_string(expr.value), frozenset()) - if isinstance(expr, self.dax.Boolean): - return _SqlFragment("TRUE" if expr.value else "FALSE", frozenset()) - if isinstance(expr, self.dax.Blank): - return _SqlFragment("NULL", frozenset()) - if isinstance(expr, self.dax.Parameter): - return _SqlFragment(f"@{expr.name}", frozenset()) - if isinstance(expr, self.dax.Identifier): - return self._translate_identifier(expr.name) - if isinstance(expr, self.dax.BracketRef): - return self._translate_identifier(expr.name) - if isinstance(expr, self.dax.TableColumnRef): - return self._translate_table_column(expr.table.name, expr.column) - if isinstance(expr, self.dax.HierarchyRef): - column = expr.levels[-1] if expr.levels else expr.column - return self._translate_table_column(expr.table.name, column) - if isinstance(expr, self.dax.Unary): - inner = self._translate_scalar(expr.expr) - if expr.op == self.dax.UnaryOp.not_: - return inner.wrap(f"NOT {inner.sql}") - if expr.op == self.dax.UnaryOp.minus: - return inner.wrap(f"-{inner.sql}") - if expr.op == self.dax.UnaryOp.plus: - return inner.wrap(f"+{inner.sql}") - if isinstance(expr, self.dax.Binary): - return self._translate_binary(expr) - if isinstance(expr, self.dax.VarBlock): - return self._with_vars(expr, self._translate_scalar, expr.body) - if isinstance(expr, self.dax.Paren): - inner = self._translate_scalar(expr.expr) - return inner.wrap(f"({inner.sql})") - if isinstance(expr, self.dax.TableConstructor): - raise DaxTranslationError("Table constructor not valid in scalar context") - if isinstance(expr, self.dax.FunctionCall): - return self._translate_function_scalar(expr) - - raise DaxTranslationError(f"Unsupported DAX expression type '{type(expr).__name__}'") - - def _translate_binary(self, expr: Any) -> _SqlFragment: - left = self._translate_scalar(expr.left) - right = self._translate_scalar(expr.right) - op = expr.op - - if op == self.dax.BinaryOp.in_: - in_list = self._translate_in_list(expr.right) - sql = f"{left.sql} IN {in_list}" - return _SqlFragment(sql, left.columns | right.columns) - - op_map = { - self.dax.BinaryOp.or_: "OR", - self.dax.BinaryOp.and_: "AND", - self.dax.BinaryOp.eq: "=", - self.dax.BinaryOp.strict_eq: "=", - self.dax.BinaryOp.neq: "<>", - self.dax.BinaryOp.lt: "<", - self.dax.BinaryOp.lte: "<=", - self.dax.BinaryOp.gt: ">", - self.dax.BinaryOp.gte: ">=", - self.dax.BinaryOp.concat: "||", - self.dax.BinaryOp.add: "+", - self.dax.BinaryOp.sub: "-", - self.dax.BinaryOp.mul: "*", - self.dax.BinaryOp.div: "/", - self.dax.BinaryOp.pow: "POWER", - } - - if op == self.dax.BinaryOp.pow: - sql = f"POWER({left.sql}, {right.sql})" - else: - op_sql = op_map.get(op) - if not op_sql: - raise DaxTranslationError("Unsupported binary operator") - sql = f"({left.sql} {op_sql} {right.sql})" - - return _SqlFragment(sql, left.columns | right.columns) - - def _translate_function_scalar(self, call: Any) -> _SqlFragment: - name = call.name.lower() - args = call.args - - if name in ("ignore", "nonvisual"): - if not args: - raise DaxTranslationError(f"{call.name} requires an argument") - if len(args) > 1: - raise DaxTranslationError(f"{call.name} supports exactly one argument") - return self._translate_scalar(args[0]) - if name == "evaluateandlog": - if not args: - raise DaxTranslationError("EVALUATEANDLOG requires an argument") - if len(args) > 1: - raise DaxTranslationError("EVALUATEANDLOG supports exactly one argument") - return self._translate_scalar(args[0]) - if name == "nameof": - return self._translate_nameof(args) - if name == "convert": - return self._translate_convert(args) - if name == "lookupvalue": - return self._translate_lookupvalue(args) - if name == "related": - return self._translate_related(args) - if name == "value": - return self._translate_value(args) - if name == "concatenate": - return self._translate_concatenate(args) - if name == "concatenatex": - return self._translate_concatenatex(args) - if name == "roundup": - return self._translate_roundup(args) - if name == "round": - return self._translate_round(args) - if name == "rounddown": - return self._translate_rounddown(args) - if name == "int": - return self._translate_int(args) - if name == "trunc": - return self._translate_trunc(args) - if name == "mround": - return self._translate_mround(args) - if name == "ceiling": - return self._translate_ceiling(args) - if name == "floor": - return self._translate_floor(args) - if name == "abs": - return self._translate_abs(args) - if name == "mod": - return self._translate_mod(args) - if name == "power": - return self._translate_power(args) - if name == "sqrt": - return self._translate_sqrt(args) - if name == "exp": - return self._translate_exp(args) - if name == "ln": - return self._translate_ln(args) - if name == "log10": - return self._translate_log10(args) - if name == "log": - return self._translate_log(args) - if name == "pi": - return self._translate_pi(args) - if name == "blank": - if args: - raise DaxTranslationError("BLANK does not take arguments") - return _SqlFragment("NULL", frozenset()) - if name == "true": - if args: - raise DaxTranslationError("TRUE does not take arguments") - return _SqlFragment("TRUE", frozenset()) - if name == "false": - if args: - raise DaxTranslationError("FALSE does not take arguments") - return _SqlFragment("FALSE", frozenset()) - if name == "if": - return self._translate_if(args) - if name == "switch": - return self._translate_switch(args) - if name == "selectedvalue": - return self._translate_selectedvalue(args) - if name in ("hasonevalue", "hasonefilter"): - return self._translate_hasone(args) - if name in ("firstnonblank", "firstnonblankvalue"): - return self._translate_first_last_nonblank(args, pick="first") - if name in ("lastnonblank", "lastnonblankvalue"): - return self._translate_first_last_nonblank(args, pick="last") - if name in ("firstdate", "lastdate"): - return self._translate_first_last_date(args, pick="first" if name == "firstdate" else "last") - if name in ("startofmonth", "startofquarter", "startofyear"): - grain = {"startofmonth": "month", "startofquarter": "quarter", "startofyear": "year"}[name] - return self._translate_period_boundary_date(args, grain=grain, end=False) - if name in ("endofmonth", "endofquarter", "endofyear"): - grain = {"endofmonth": "month", "endofquarter": "quarter", "endofyear": "year"}[name] - return self._translate_period_boundary_date(args, grain=grain, end=True) - if name == "date": - return self._translate_date_ctor(args) - if name == "time": - return self._translate_time_ctor(args) - if name == "datevalue": - return self._translate_datevalue(args) - if name == "timevalue": - return self._translate_timevalue(args) - if name == "edate": - return self._translate_edate(args) - if name == "eomonth": - return self._translate_eomonth(args) - if name == "datediff": - return self._translate_datediff(args) - if name == "weekday": - return self._translate_weekday(args) - if name == "weeknum": - return self._translate_weeknum(args) - if name == "containsstring": - return self._translate_containsstring(args, exact=False) - if name == "containsstringexact": - return self._translate_containsstring(args, exact=True) - if name == "containsrow": - return self._translate_containsrow(args) - if name == "upper": - if not args: - raise DaxTranslationError("UPPER requires an argument") - if len(args) > 1: - raise DaxTranslationError("UPPER supports exactly one argument") - target = self._translate_scalar(args[0]) - return _SqlFragment(f"UPPER({target.sql})", target.columns) - if name == "lower": - if not args: - raise DaxTranslationError("LOWER requires an argument") - if len(args) > 1: - raise DaxTranslationError("LOWER supports exactly one argument") - target = self._translate_scalar(args[0]) - return _SqlFragment(f"LOWER({target.sql})", target.columns) - if name == "len": - return self._translate_len(args) - if name == "replace": - return self._translate_replace(args) - if name == "substitute": - return self._translate_substitute(args) - if name == "rept": - return self._translate_rept(args) - if name == "trim": - return self._translate_trim(args) - if name == "left": - return self._translate_left(args) - if name == "right": - return self._translate_right(args) - if name == "mid": - return self._translate_mid(args) - if name == "search": - return self._translate_find_search(args, case_sensitive=False, func="SEARCH") - if name == "find": - return self._translate_find_search(args, case_sensitive=True, func="FIND") - if name == "exact": - return self._translate_exact(args) - if name == "today": - if args: - raise DaxTranslationError("TODAY does not take arguments") - return _SqlFragment("CURRENT_DATE", frozenset()) - if name == "now": - if args: - raise DaxTranslationError("NOW does not take arguments") - return _SqlFragment("CURRENT_TIMESTAMP", frozenset()) - if name == "utcnow": - if args: - raise DaxTranslationError("UTCNOW does not take arguments") - return _SqlFragment("CURRENT_TIMESTAMP", frozenset()) - if name == "utctoday": - if args: - raise DaxTranslationError("UTCTODAY does not take arguments") - return _SqlFragment("CURRENT_DATE", frozenset()) - if name in ("year", "month", "day", "hour", "minute", "second", "quarter"): - return self._translate_date_part(args, part=name) - if name == "rand": - if args: - raise DaxTranslationError("RAND does not take arguments") - return _SqlFragment("RANDOM()", frozenset()) - if name == "randbetween": - return self._translate_randbetween(args) - if name == "format": - return self._translate_format(args) - if name == "iferror": - return self._translate_iferror(args) - if name == "isinscope": - return self._translate_isinscope(args) - if name in ("isfiltered", "iscrossfiltered"): - return self._translate_isfiltered(args) - if name == "coalesce": - return self._translate_coalesce(args) - if name == "divide": - return self._translate_divide(args) - if name == "and": - return self._translate_and_or(args, op="AND") - if name == "or": - return self._translate_and_or(args, op="OR") - if name == "not": - if not args: - raise DaxTranslationError("NOT requires an argument") - if len(args) > 1: - raise DaxTranslationError("NOT supports exactly one argument") - inner = self._translate_scalar(args[0]) - return inner.wrap(f"NOT {inner.sql}") - if name == "isblank": - if not args: - raise DaxTranslationError("ISBLANK requires an argument") - if len(args) > 1: - raise DaxTranslationError("ISBLANK supports exactly one argument") - inner = self._translate_scalar(args[0]) - return inner.wrap(f"{inner.sql} IS NULL") - if name == "isempty": - return self._translate_isempty(args) - if name == "calculate": - with self._allow_cross_table_context(): - metric = self._translate_calculate(call) - return self._metric_to_scalar_fragment(metric) - if name in ("min", "max"): - return self._translate_min_max(call) - if name in ( - "sumx", - "averagex", - "avgx", - "minx", - "maxx", - "medianx", - "countx", - "countax", - "totalytd", - "totalmtd", - "totalqtd", - "totalwtd", - ): - with self._allow_cross_table_context(): - metric = self.translate_metric(call) - return self._metric_to_scalar_fragment(metric) - if name in ( - "sum", - "average", - "averagea", - "avg", - "min", - "mina", - "max", - "maxa", - "median", - "count", - "countrows", - "counta", - "countblank", - "distinctcount", - "distinctcountnoblank", - "approximatedistinctcount", - ): - return self._translate_inline_aggregate(call) - if name in ("selectedmeasure", "selectedmeasurename", "selectedmeasureformatstring", "isselectedmeasure"): - raise DaxTranslationError(f"{call.name} is only supported in calculation group expressions") - - if self._is_table_function_name(name): - raise DaxTranslationError(f"{call.name} returns a table and is not valid in scalar context") - if name in ("userelationship", "crossfilter"): - raise DaxTranslationError(f"{call.name} is only valid in CALCULATE filter arguments") - - raise DaxTranslationError(f"Unsupported scalar function '{call.name}'") - - def _is_table_function_name(self, name: str) -> bool: - return name in { - "filter", - "row", - "selectcolumns", - "addcolumns", - "summarizecolumns", - "summarize", - "groupby", - "topn", - "topnperlevel", - "union", - "crossjoin", - "naturalinnerjoin", - "naturalleftouterjoin", - "intersect", - "except", - "topnskip", - "calendar", - "generateseries", - "datatable", - "relatedtable", - "calculatetable", - "addmissingitems", - "treatas", - "datesbetween", - "datesinperiod", - "datesytd", - "datesmtd", - "datesqtd", - "dateswtd", - "dateadd", - "parallelperiod", - "sameperiodlastyear", - "previousday", - "previousweek", - "previousmonth", - "previousquarter", - "previousyear", - "nextday", - "nextweek", - "nextmonth", - "nextquarter", - "nextyear", - "rollup", - "rollupgroup", - "rollupaddissubtotal", - "rollupissubtotal", - "values", - "filters", - "distinct", - "renamecolumns", - "keepcolumns", - "removecolumns", - "substitutewithindex", - "detailrows", - "all", - "allnoblankrow", - "allselected", - "allcrossfiltered", - "removefilters", - "allexcept", - "generate", - "generateall", - "currentgroup", - } - - def _translate_inline_aggregate(self, call: Any) -> _SqlFragment: - name = call.name.lower() - agg_map = { - "sum": "SUM", - "average": "AVG", - "averagea": "AVG", - "avg": "AVG", - "min": "MIN", - "mina": "MIN", - "max": "MAX", - "maxa": "MAX", - "median": "MEDIAN", - "count": "COUNT", - "countrows": "COUNT", - "counta": "COUNT", - "countblank": "COUNT", - "distinctcount": "COUNT", - "distinctcountnoblank": "COUNT", - "approximatedistinctcount": "COUNT", - } - func = agg_map[name] - if name == "countrows": - if len(call.args) > 1: - raise DaxTranslationError("COUNTROWS supports at most one argument") - if call.args: - target = self._unwrap(call.args[0]) - if isinstance(target, self.dax.FunctionCall) and target.name.lower() == "currentgroup": - return _SqlFragment("COUNT(*)", frozenset()) - distinct_translation = self._translate_countrows_distinct_table(call.args[0]) - if distinct_translation is not None and distinct_translation.sql: - columns = set(self._columns_from_sql(distinct_translation.sql)) - return _SqlFragment(f"COUNT(DISTINCT {distinct_translation.sql})", frozenset(columns)) - target = self._unwrap(call.args[0]) - table_name = self._table_name_from_expr(target) - if table_name is not None: - self._ensure_table_context(table_name) - default_table = self.model_name or self._base_table - if default_table and table_name.lower() == default_table.lower(): - return _SqlFragment("COUNT(*)", frozenset()) - grouped_count = self._translate_grouped_countrows_for_table(table_name) - if grouped_count is not None: - return grouped_count - filters, _overrides = self._filters_from_table(call.args[0]) - distinct_table = self._countrows_distinct_table_name(call.args[0]) - if distinct_table is not None: - grouped_distinct = self._translate_grouped_countrows_distinct_for_table( - distinct_table, filters=filters - ) - if grouped_distinct is not None: - return grouped_distinct - if filters: - sql = self._render_aggregate_sql("count", None, filters) - columns = set() - for clause in filters: - columns.update(self._columns_from_sql(clause)) - return _SqlFragment(sql, frozenset(columns)) - base_table = self._countrows_base_table_name(call.args[0]) - if base_table is not None: - grouped_count = self._translate_grouped_countrows_for_table(base_table) - if grouped_count is not None: - return grouped_count - from_sql, _wrapped = self._table_source_from_expr(call.args[0]) - return _SqlFragment(f"(SELECT COUNT(*) FROM {from_sql})", frozenset()) - return _SqlFragment("COUNT(*)", frozenset()) - if not call.args: - raise DaxTranslationError(f"{call.name} requires an argument") - if len(call.args) > 1: - raise DaxTranslationError(f"{call.name} supports exactly one argument") - arg = self._translate_scalar(call.args[0]) - if name == "countblank": - return _SqlFragment(f"COUNT(CASE WHEN {arg.sql} IS NULL THEN 1 END)", arg.columns) - if name == "distinctcount": - return _SqlFragment(f"COUNT(DISTINCT {arg.sql})", arg.columns) - if name == "distinctcountnoblank": - return _SqlFragment(f"COUNT(DISTINCT {arg.sql})", arg.columns) - if name == "approximatedistinctcount": - return _SqlFragment(f"COUNT(DISTINCT {arg.sql})", arg.columns) - return _SqlFragment(f"{func}({arg.sql})", arg.columns) - - def _translate_min_max(self, call: Any) -> _SqlFragment: - name = call.name.lower() - if not call.args: - raise DaxTranslationError(f"{call.name} requires an argument") - if len(call.args) == 1: - return self._translate_inline_aggregate(call) - if len(call.args) == 2: - left = self._translate_scalar(call.args[0]) - right = self._translate_scalar(call.args[1]) - func = "LEAST" if name == "min" else "GREATEST" - return _SqlFragment(f"{func}({left.sql}, {right.sql})", left.columns | right.columns) - raise DaxTranslationError(f"{call.name} supports one aggregate argument or two scalar arguments") - - def _translate_grouped_countrows_for_table(self, table_name: str) -> _SqlFragment | None: - if not self._current_group_by_columns: - return None - - group_tables = {col.table for col in self._current_group_by_columns if col.table} - if not group_tables: - return None - - count_column = self._grouped_countrows_column_from_relationship_path(table_name, group_tables) - if count_column is None: - table_key = table_name.lower() - has_relationship_path = any( - group_table.lower() == table_key or self._find_relationship_path(group_table, table_name) is not None - for group_table in group_tables - ) - if not has_relationship_path: - return None - count_column = self._representative_table_column(table_name) - if count_column is None: - return None - - count_sql = self._column_sql(table_name, count_column) - return _SqlFragment(f"COUNT({count_sql})", frozenset({ColumnRef(table_name, count_column)})) - - def _grouped_countrows_column_from_relationship_path(self, table_name: str, group_tables: set[str]) -> str | None: - table_key = table_name.lower() - best_path: list[tuple[str, str, str, str]] | None = None - for group_table in group_tables: - if group_table.lower() == table_key: - return self._representative_table_column(table_name) - path = self._find_relationship_path(group_table, table_name) - if path is None: - continue - if best_path is None or len(path) < len(best_path): - best_path = path - if not best_path: - return None - _from_table, to_table, _from_col, to_col = best_path[-1] - if to_table.lower() != table_key: - return None - return to_col - - def _representative_table_column(self, table_name: str) -> str | None: - table_key = table_name.lower() - for candidate_table, column_map in self.column_sql_by_table.items(): - if candidate_table.lower() != table_key: - continue - if column_map: - return next(iter(column_map)) - break - for edge in self._relationship_edges: - if edge.from_table.lower() == table_key: - return edge.from_column - if edge.to_table.lower() == table_key: - return edge.to_column - return None - - def _countrows_base_table_name(self, expr: Any) -> str | None: - expr = self._unwrap(expr) - table_name = self._table_name_from_expr(expr) - if table_name is not None: - return table_name - if isinstance(expr, self.dax.FunctionCall) and expr.args: - name = expr.name.lower() - if name in ("calculatetable", "keepfilters", "nonvisual"): - return self._countrows_base_table_name(expr.args[0]) - return None - - def _translate_grouped_countrows_distinct_for_table( - self, table_name: str, filters: list[str] - ) -> _SqlFragment | None: - count_column = self._grouped_countrows_column_from_relationship_path( - table_name, {col.table for col in self._current_group_by_columns if col.table} - ) - if count_column is None: - return None - count_sql = self._column_sql(table_name, count_column) - sql = self._render_aggregate_sql("count_distinct", count_sql, filters) - columns = {ColumnRef(table_name, count_column)} - for clause in filters: - columns.update(self._columns_from_sql(clause)) - return _SqlFragment(sql, frozenset(columns)) - - def _countrows_distinct_table_name(self, expr: Any) -> str | None: - expr = self._unwrap(expr) - if not isinstance(expr, self.dax.FunctionCall): - return None - name = expr.name.lower() - if name in ("values", "filters", "distinct"): - if not expr.args: - return None - return self._table_name_from_expr(expr.args[0]) - if name in ("calculatetable", "keepfilters", "nonvisual"): - if not expr.args: - return None - return self._countrows_distinct_table_name(expr.args[0]) - return None - - def _metric_to_scalar_fragment(self, metric: MetricTranslation) -> _SqlFragment: - if metric.type in ("time_comparison", "cumulative"): - return self._translate_time_metric_scalar(metric) - - filters = list(metric.filters or []) - if metric.agg: - sql = self._render_aggregate_sql(metric.agg, metric.sql, filters) - else: - if not metric.sql: - raise DaxTranslationError("CALCULATE requires a scalar or aggregate expression") - if filters: - predicate = " AND ".join(filters) - sql = f"CASE WHEN {predicate} THEN {metric.sql} ELSE NULL END" - else: - sql = metric.sql - - columns = set() - if metric.sql: - columns.update(self._columns_from_sql(metric.sql)) - if metric.source_table and not any( - col.table and col.table.lower() == metric.source_table.lower() for col in columns - ): - representative = self._representative_table_column(metric.source_table) - columns.add(ColumnRef(metric.source_table, representative or "")) - for clause in filters: - columns.update(self._columns_from_sql(clause)) - return _SqlFragment(sql, frozenset(columns)) - - def _translate_time_metric_scalar(self, metric: MetricTranslation) -> _SqlFragment: - base_agg, base_sql, base_filters = self._time_metric_base(metric) - base_expr = self._render_aggregate_sql(base_agg, base_sql, base_filters) - - order_col, partition_cols = self._time_window_context(metric) - if order_col is None: - return _SqlFragment(base_expr, frozenset(self._columns_from_sql(base_expr))) - - partition_sql = f"PARTITION BY {', '.join(partition_cols)} " if partition_cols else "" - if metric.type == "cumulative": - if metric.window: - parts = metric.window.split() - if len(parts) == 2: - num, unit = parts - sql = ( - f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} " - f"RANGE BETWEEN INTERVAL '{num} {unit}' PRECEDING AND CURRENT ROW)" - ) - elif len(parts) == 3 and parts[2].lower() == "following": - num, unit = parts[0], parts[1] - sql = ( - f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} " - f"RANGE BETWEEN CURRENT ROW AND INTERVAL '{num} {unit}' FOLLOWING)" - ) - else: - sql = f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" - elif metric.grain_to_date: - grain_partition = f"DATE_TRUNC('{metric.grain_to_date}', {order_col})" - all_parts = [grain_partition, *partition_cols] - sql = ( - f"{base_expr} OVER (PARTITION BY {', '.join(all_parts)} " - f"ORDER BY {order_col} ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" - ) - else: - sql = f"{base_expr} OVER ({partition_sql}ORDER BY {order_col} ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" - return _SqlFragment(sql, frozenset(self._columns_from_sql(sql))) - - lag_offset = self._time_comparison_lag_offset(metric) - if metric.calculation == "previous_value": - sql = f"LAG({base_expr}, {lag_offset}) OVER ({partition_sql}ORDER BY {order_col})" - return _SqlFragment(sql, frozenset(self._columns_from_sql(sql))) - raise DaxTranslationError(f"Unsupported time comparison calculation '{metric.calculation}'") - - def _time_metric_base(self, metric: MetricTranslation) -> tuple[str, str | None, list[str]]: - if metric.type == "time_comparison": - agg = metric.inline_base_agg or self._lookup_measure_agg(metric.base_metric or "") - sql = metric.inline_base_sql - if sql is None and metric.base_metric: - sql = self._lookup_measure_sql(metric.base_metric) - if sql is None and metric.base_metric: - sql = metric.base_metric - filters = list(metric.inline_base_filters or []) - else: - agg = metric.agg - sql = metric.sql - filters = list(metric.filters or []) - - if agg is None: - agg = "sum" - return agg, sql, filters - - def _time_window_context(self, metric: MetricTranslation) -> tuple[str | None, list[str]]: - group_cols = list(self._current_group_by_columns) - if not group_cols: - return None, [] - - grouped_sql: list[str] = [] - time_candidates: list[str] = [] - for col in group_cols: - if not col.table: - continue - col_sql = self._column_sql(col.table, col.column) - grouped_sql.append(col_sql) - known_time = col.column in self.time_dimensions_by_table.get(col.table, set()) - if known_time: - time_candidates.append(col_sql) - - if metric.window_order: - order_sql = metric.window_order - elif time_candidates: - order_sql = time_candidates[0] - elif grouped_sql: - order_sql = grouped_sql[0] - else: - return None, [] - - partition_cols = [sql for sql in grouped_sql if sql != order_sql] - return order_sql, partition_cols - - def _time_comparison_lag_offset(self, metric: MetricTranslation) -> int: - if metric.time_offset: - parts = metric.time_offset.split() - if parts: - try: - return abs(int(parts[0])) - except ValueError: - pass - by_comp = { - "dod": 1, - "wow": 1, - "mom": 1, - "qoq": 1, - "yoy": 1, - "prior_period": 1, - } - return by_comp.get(metric.comparison_type or "", 1) - - def _render_aggregate_sql(self, agg: str, sql: str | None, filters: list[str]) -> str: - predicate = " AND ".join(filters) if filters else None - if agg == "count": - if sql is None: - if predicate: - return f"COUNT(CASE WHEN {predicate} THEN 1 END)" - return "COUNT(*)" - if predicate: - return f"COUNT(CASE WHEN {predicate} THEN {sql} END)" - return f"COUNT({sql})" - - if sql is None: - raise DaxTranslationError(f"{agg} aggregation requires a SQL expression") - - if agg == "count_distinct": - if predicate: - return f"COUNT(DISTINCT CASE WHEN {predicate} THEN {sql} END)" - return f"COUNT(DISTINCT {sql})" - - func_map = {"sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX", "median": "MEDIAN"} - func = func_map.get(agg) - if func is None: - raise DaxTranslationError(f"Unsupported aggregate '{agg}' in scalar context") - if predicate: - return f"{func}(CASE WHEN {predicate} THEN {sql} ELSE NULL END)" - return f"{func}({sql})" - - def _translate_nameof(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("NAMEOF requires an argument") - if len(args) > 1: - raise DaxTranslationError("NAMEOF supports exactly one argument") - target = self._unwrap(args[0]) - if isinstance(target, self.dax.TableColumnRef): - return _SqlFragment(self._quote_string(f"{target.table.name}[{target.column}]"), frozenset()) - if isinstance(target, self.dax.HierarchyRef): - col = target.levels[-1] if target.levels else target.column - return _SqlFragment(self._quote_string(f"{target.table.name}[{col}]"), frozenset()) - if isinstance(target, self.dax.BracketRef): - return _SqlFragment(self._quote_string(target.name), frozenset()) - if isinstance(target, self.dax.Identifier): - return _SqlFragment(self._quote_string(target.name), frozenset()) - resolved = self._translate_scalar(args[0]) - return _SqlFragment(self._quote_string(resolved.sql), resolved.columns) - - def _translate_convert(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("CONVERT requires value and datatype arguments") - if len(args) > 2: - raise DaxTranslationError("CONVERT supports exactly value and datatype arguments") - value = self._translate_scalar(args[0]) - dtype = self._identifier_literal_value(args[1]) - if dtype is None: - return value - normalized = dtype.strip().lower() - cast_type = { - "integer": "BIGINT", - "int64": "BIGINT", - "double": "DOUBLE", - "decimal": "DECIMAL", - "currency": "DECIMAL", - "string": "VARCHAR", - "boolean": "BOOLEAN", - "datetime": "TIMESTAMP", - "date": "DATE", - "time": "TIME", - }.get(normalized) - if cast_type is None: - return value - return _SqlFragment(f"CAST({value.sql} AS {cast_type})", value.columns) - - def _translate_lookupvalue(self, args: list[Any]) -> _SqlFragment: - if len(args) < 3: - raise DaxTranslationError("LOOKUPVALUE requires result column and at least one search pair") - - result_expr = self._unwrap(args[0]) - if isinstance(result_expr, self.dax.TableColumnRef): - result_table = result_expr.table.name - elif isinstance(result_expr, self.dax.HierarchyRef): - result_table = result_expr.table.name - else: - raise DaxTranslationError("LOOKUPVALUE result argument must be a table column reference") - - with self._allow_cross_table_context(): - result_col = self._translate_scalar(args[0]) - - search_args = list(args[1:]) - alternate: _SqlFragment | None = None - if len(search_args) % 2 == 1: - alternate = self._translate_scalar(search_args[-1]) - search_args = search_args[:-1] - - if len(search_args) < 2 or len(search_args) % 2 != 0: - raise DaxTranslationError("LOOKUPVALUE requires search column/value pairs") - - predicates: list[str] = [] - outer_columns: set[ColumnRef] = set() - for idx in range(0, len(search_args), 2): - search_col = self._translate_scalar(search_args[idx]) - search_val = self._translate_scalar(search_args[idx + 1]) - predicates.append(f"{search_col.sql} = {search_val.sql}") - outer_columns.update(search_val.columns) - - table_sql = self._table_sql(result_table) - where_sql = f" WHERE {' AND '.join(predicates)}" if predicates else "" - subquery = f"(SELECT {result_col.sql} FROM {table_sql}{where_sql} LIMIT 1)" - if alternate is None: - return _SqlFragment(subquery, frozenset(outer_columns)) - outer_columns.update(alternate.columns) - return _SqlFragment(f"COALESCE({subquery}, {alternate.sql})", frozenset(outer_columns)) - - def _translate_related(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("RELATED requires an argument") - if len(args) > 1: - raise DaxTranslationError("RELATED supports exactly one argument") - with self._allow_cross_table_context(): - return self._translate_scalar(args[0]) - - def _translate_value(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("VALUE requires an argument") - if len(args) > 1: - raise DaxTranslationError("VALUE supports exactly one argument") - target = self._translate_scalar(args[0]) - return _SqlFragment(f"CAST({target.sql} AS DOUBLE)", target.columns) - - def _translate_concatenate(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("CONCATENATE requires two arguments") - if len(args) > 2: - raise DaxTranslationError("CONCATENATE supports exactly two arguments") - left = self._translate_scalar(args[0]) - right = self._translate_scalar(args[1]) - return _SqlFragment(f"({left.sql} || {right.sql})", left.columns | right.columns) - - def _translate_concatenatex(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("CONCATENATEX requires table and expression arguments") - - table_expr = args[0] - value_expr = args[1] - delimiter_expr = args[2] if len(args) > 2 else None - order_args = args[3:] if len(args) > 3 else [] - - table_target = self._unwrap(table_expr) - base_table_name = self._table_name_from_expr(table_target) - wrapped = base_table_name is None - from_sql, _ = self._table_source_from_expr(table_expr) - - value_fragment: _SqlFragment - delimiter_fragment: _SqlFragment - order_by_parts: list[str] - order_columns: set[ColumnRef] - qualifier_ctx = self._prefer_unqualified_base_table_context() if wrapped else nullcontext() - with qualifier_ctx: - value_fragment = ( - self._translate_projection_scalar(value_expr) if wrapped else self._translate_scalar(value_expr) - ) - if delimiter_expr is None: - delimiter_fragment = _SqlFragment("''", frozenset()) - else: - delimiter_fragment = ( - self._translate_projection_scalar(delimiter_expr) - if wrapped - else self._translate_scalar(delimiter_expr) - ) - order_by_parts, order_columns = self._parse_order_by_parts_with_columns(order_args, projection_safe=wrapped) - - if base_table_name is not None: - tables_in_order = [base_table_name] - seen_tables = {base_table_name.lower()} - self._append_tables(tables_in_order, seen_tables, value_fragment.columns) - self._append_tables(tables_in_order, seen_tables, delimiter_fragment.columns) - self._append_tables(tables_in_order, seen_tables, order_columns) - from_sql = self._build_from_clause_for_tables(tables_in_order) - else: - value_fragment = value_fragment.wrap(_rewrite_expr_for_alias(value_fragment.sql, "t")) - delimiter_fragment = delimiter_fragment.wrap(_rewrite_expr_for_alias(delimiter_fragment.sql, "t")) - order_by_parts = [_rewrite_expr_for_alias(order_sql, "t") for order_sql in order_by_parts] - - order_sql = f" ORDER BY {', '.join(order_by_parts)}" if order_by_parts else "" - aggregate_sql = ( - f"STRING_AGG(CAST({value_fragment.sql} AS VARCHAR), CAST({delimiter_fragment.sql} AS VARCHAR){order_sql})" - ) - columns = value_fragment.columns | delimiter_fragment.columns | frozenset(order_columns) - return _SqlFragment(f"(SELECT {aggregate_sql} FROM {from_sql})", columns) - - def _translate_roundup(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("ROUNDUP requires number and num_digits arguments") - value = self._translate_scalar(args[0]) - digits = self._translate_scalar(args[1]) - sql = ( - f"CASE WHEN {digits.sql} >= 0 " - f"THEN SIGN({value.sql}) * CEIL(ABS({value.sql}) * POWER(10, {digits.sql})) / POWER(10, {digits.sql}) " - f"ELSE SIGN({value.sql}) * CEIL(ABS({value.sql}) / POWER(10, -({digits.sql}))) * POWER(10, -({digits.sql})) END" - ) - return _SqlFragment(sql, value.columns | digits.columns) - - def _translate_round(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("ROUND requires number and num_digits arguments") - value = self._translate_scalar(args[0]) - digits = self._translate_scalar(args[1]) - sql = ( - f"CASE WHEN {digits.sql} >= 0 " - f"THEN SIGN({value.sql}) * FLOOR(ABS({value.sql}) * POWER(10, {digits.sql}) + 0.5) / POWER(10, {digits.sql}) " - f"ELSE SIGN({value.sql}) * FLOOR(ABS({value.sql}) / POWER(10, -({digits.sql})) + 0.5) * POWER(10, -({digits.sql})) END" - ) - return _SqlFragment(sql, value.columns | digits.columns) - - def _translate_rounddown(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("ROUNDDOWN requires number and num_digits arguments") - value = self._translate_scalar(args[0]) - digits = self._translate_scalar(args[1]) - sql = ( - f"CASE WHEN {digits.sql} >= 0 " - f"THEN SIGN({value.sql}) * FLOOR(ABS({value.sql}) * POWER(10, {digits.sql})) / POWER(10, {digits.sql}) " - f"ELSE SIGN({value.sql}) * FLOOR(ABS({value.sql}) / POWER(10, -({digits.sql}))) * POWER(10, -({digits.sql})) END" - ) - return _SqlFragment(sql, value.columns | digits.columns) - - def _translate_int(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("INT requires an argument") - if len(args) > 1: - raise DaxTranslationError("INT supports exactly one argument") - value = self._translate_scalar(args[0]) - return _SqlFragment(f"FLOOR({value.sql})", value.columns) - - def _translate_trunc(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("TRUNC requires a number argument") - if len(args) > 2: - raise DaxTranslationError("TRUNC supports at most number and num_digits arguments") - value = self._translate_scalar(args[0]) - digits = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("0", frozenset()) - sql = ( - f"CASE WHEN {digits.sql} >= 0 " - f"THEN SIGN({value.sql}) * FLOOR(ABS({value.sql}) * POWER(10, {digits.sql})) / POWER(10, {digits.sql}) " - f"ELSE SIGN({value.sql}) * FLOOR(ABS({value.sql}) / POWER(10, -({digits.sql}))) * POWER(10, -({digits.sql})) END" - ) - return _SqlFragment(sql, value.columns | digits.columns) - - def _translate_mround(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("MROUND requires number and multiple arguments") - value = self._translate_scalar(args[0]) - multiple = self._translate_scalar(args[1]) - sql = ( - f"CASE WHEN {multiple.sql} = 0 THEN 0 " - f"ELSE SIGN({value.sql}) * FLOOR((ABS({value.sql}) / ABS({multiple.sql})) + 0.5) * ABS({multiple.sql}) END" - ) - return _SqlFragment(sql, value.columns | multiple.columns) - - def _translate_ceiling(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("CEILING requires at least one argument") - if len(args) > 2: - raise DaxTranslationError("CEILING supports at most number and significance arguments") - value = self._translate_scalar(args[0]) - if len(args) == 1: - return _SqlFragment(f"CEIL({value.sql})", value.columns) - significance = self._translate_scalar(args[1]) - sql = f"(CEIL({value.sql} / {significance.sql}) * {significance.sql})" - return _SqlFragment(sql, value.columns | significance.columns) - - def _translate_floor(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("FLOOR requires at least one argument") - if len(args) > 2: - raise DaxTranslationError("FLOOR supports at most number and significance arguments") - value = self._translate_scalar(args[0]) - if len(args) == 1: - return _SqlFragment(f"FLOOR({value.sql})", value.columns) - significance = self._translate_scalar(args[1]) - sql = f"(FLOOR({value.sql} / {significance.sql}) * {significance.sql})" - return _SqlFragment(sql, value.columns | significance.columns) - - def _translate_abs(self, args: list[Any]) -> _SqlFragment: - if len(args) != 1: - raise DaxTranslationError("ABS requires exactly one argument") - value = self._translate_scalar(args[0]) - return _SqlFragment(f"ABS({value.sql})", value.columns) - - def _translate_mod(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("MOD requires number and divisor arguments") - number = self._translate_scalar(args[0]) - divisor = self._translate_scalar(args[1]) - return _SqlFragment(f"MOD({number.sql}, {divisor.sql})", number.columns | divisor.columns) - - def _translate_power(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("POWER requires number and exponent arguments") - number = self._translate_scalar(args[0]) - exponent = self._translate_scalar(args[1]) - return _SqlFragment(f"POWER({number.sql}, {exponent.sql})", number.columns | exponent.columns) - - def _translate_sqrt(self, args: list[Any]) -> _SqlFragment: - if len(args) != 1: - raise DaxTranslationError("SQRT requires exactly one argument") - value = self._translate_scalar(args[0]) - return _SqlFragment(f"SQRT({value.sql})", value.columns) - - def _translate_exp(self, args: list[Any]) -> _SqlFragment: - if len(args) != 1: - raise DaxTranslationError("EXP requires exactly one argument") - value = self._translate_scalar(args[0]) - return _SqlFragment(f"EXP({value.sql})", value.columns) - - def _translate_ln(self, args: list[Any]) -> _SqlFragment: - if len(args) != 1: - raise DaxTranslationError("LN requires exactly one argument") - value = self._translate_scalar(args[0]) - return _SqlFragment(f"LN({value.sql})", value.columns) - - def _translate_log10(self, args: list[Any]) -> _SqlFragment: - if len(args) != 1: - raise DaxTranslationError("LOG10 requires exactly one argument") - value = self._translate_scalar(args[0]) - return _SqlFragment(f"LOG10({value.sql})", value.columns) - - def _translate_log(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("LOG requires at least one argument") - if len(args) > 2: - raise DaxTranslationError("LOG supports at most number and base arguments") - value = self._translate_scalar(args[0]) - if len(args) == 1: - return _SqlFragment(f"LOG10({value.sql})", value.columns) - base = self._translate_scalar(args[1]) - return _SqlFragment(f"(LN({value.sql}) / LN({base.sql}))", value.columns | base.columns) - - def _translate_pi(self, args: list[Any]) -> _SqlFragment: - if args: - raise DaxTranslationError("PI does not take arguments") - return _SqlFragment("PI()", frozenset()) - - def _translate_if(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("IF requires condition") - if len(args) > 3: - raise DaxTranslationError("IF supports at most condition, value_if_true, and value_if_false arguments") - condition = self._translate_scalar(args[0]) - true_expr = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("NULL", frozenset()) - false_expr = self._translate_scalar(args[2]) if len(args) > 2 else _SqlFragment("NULL", frozenset()) - sql = f"CASE WHEN {condition.sql} THEN {true_expr.sql} ELSE {false_expr.sql} END" - columns = condition.columns | true_expr.columns | false_expr.columns - return _SqlFragment(sql, columns) - - def _translate_selectedvalue(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("SELECTEDVALUE requires an argument") - if len(args) > 2: - raise DaxTranslationError("SELECTEDVALUE supports at most column and alternate_result arguments") - target = self._translate_scalar(args[0]) - alternate = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("NULL", frozenset()) - sql = f"CASE WHEN COUNT(DISTINCT {target.sql}) = 1 THEN MIN({target.sql}) ELSE {alternate.sql} END" - return _SqlFragment(sql, target.columns | alternate.columns) - - def _translate_hasone(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("HASONEVALUE requires an argument") - if len(args) > 1: - raise DaxTranslationError("HASONEVALUE/HASONEFILTER supports exactly one argument") - target = self._translate_scalar(args[0]) - return _SqlFragment(f"(COUNT(DISTINCT {target.sql}) = 1)", target.columns) - - def _translate_first_last_nonblank(self, args: list[Any], *, pick: str) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("FIRSTNONBLANK/LASTNONBLANK requires a column and expression") - if len(args) > 2: - raise DaxTranslationError("FIRSTNONBLANK/LASTNONBLANK supports exactly column and expression arguments") - target = self._translate_scalar(args[0]) - predicate = self._translate_scalar(args[1]) - agg = "MIN" if pick == "first" else "MAX" - sql = f"{agg}(CASE WHEN {predicate.sql} IS NOT NULL THEN {target.sql} ELSE NULL END)" - return _SqlFragment(sql, target.columns | predicate.columns) - - def _translate_first_last_date(self, args: list[Any], *, pick: str) -> _SqlFragment: - if not args: - raise DaxTranslationError("FIRSTDATE/LASTDATE requires an argument") - if len(args) > 1: - raise DaxTranslationError("FIRSTDATE/LASTDATE supports exactly one argument") - target = self._translate_scalar(args[0]) - agg = "MIN" if pick == "first" else "MAX" - return _SqlFragment(f"{agg}({target.sql})", target.columns) - - def _translate_period_boundary_date(self, args: list[Any], *, grain: str, end: bool) -> _SqlFragment: - if not args: - raise DaxTranslationError("STARTOF*/ENDOF* requires an argument") - if len(args) > 1: - raise DaxTranslationError("STARTOF*/ENDOF* supports exactly one argument") - target = self._translate_scalar(args[0]) - base = f"DATE_TRUNC('{grain}', {target.sql})" - if not end: - return _SqlFragment(f"MIN({base})", target.columns) - interval = {"month": "1 month", "quarter": "3 month", "year": "1 year"}[grain] - sql = f"MIN({base} + INTERVAL '{interval}' - INTERVAL '1 day')" - return _SqlFragment(sql, target.columns) - - def _translate_date_ctor(self, args: list[Any]) -> _SqlFragment: - if len(args) != 3: - raise DaxTranslationError("DATE requires year, month, and day arguments") - year = self._translate_scalar(args[0]) - month = self._translate_scalar(args[1]) - day = self._translate_scalar(args[2]) - sql = f"MAKE_DATE({year.sql}, {month.sql}, {day.sql})" - return _SqlFragment(sql, year.columns | month.columns | day.columns) - - def _translate_time_ctor(self, args: list[Any]) -> _SqlFragment: - if len(args) != 3: - raise DaxTranslationError("TIME requires hour, minute, and second arguments") - hour = self._translate_scalar(args[0]) - minute = self._translate_scalar(args[1]) - second = self._translate_scalar(args[2]) - sql = f"MAKE_TIME({hour.sql}, {minute.sql}, {second.sql})" - return _SqlFragment(sql, hour.columns | minute.columns | second.columns) - - def _translate_datevalue(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("DATEVALUE requires an argument") - if len(args) > 1: - raise DaxTranslationError("DATEVALUE supports exactly one argument") - target = self._translate_scalar(args[0]) - return _SqlFragment(f"CAST({target.sql} AS DATE)", target.columns) - - def _translate_timevalue(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("TIMEVALUE requires an argument") - if len(args) > 1: - raise DaxTranslationError("TIMEVALUE supports exactly one argument") - target = self._translate_scalar(args[0]) - return _SqlFragment(f"CAST({target.sql} AS TIME)", target.columns) - - def _translate_edate(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("EDATE requires start date and month offset arguments") - start = self._translate_scalar(args[0]) - months = self._translate_scalar(args[1]) - sql = f"(CAST({start.sql} AS DATE) + ({months.sql}) * INTERVAL '1 month')" - return _SqlFragment(sql, start.columns | months.columns) - - def _translate_eomonth(self, args: list[Any]) -> _SqlFragment: - if len(args) != 2: - raise DaxTranslationError("EOMONTH requires start date and month offset arguments") - start = self._translate_scalar(args[0]) - months = self._translate_scalar(args[1]) - shifted = f"(CAST({start.sql} AS DATE) + ({months.sql}) * INTERVAL '1 month')" - sql = f"(DATE_TRUNC('month', {shifted}) + INTERVAL '1 month' - INTERVAL '1 day')" - return _SqlFragment(sql, start.columns | months.columns) - - def _translate_datediff(self, args: list[Any]) -> _SqlFragment: - if len(args) != 3: - raise DaxTranslationError("DATEDIFF requires start date, end date, and interval arguments") - start = self._translate_scalar(args[0]) - end = self._translate_scalar(args[1]) - unit = self._identifier_literal_value(args[2]) - if unit is None: - raise DaxTranslationError("DATEDIFF interval must be an identifier or string") - normalized = unit.lower() - if normalized.endswith("s"): - normalized = normalized[:-1] - sql = f"DATE_DIFF('{normalized}', CAST({start.sql} AS DATE), CAST({end.sql} AS DATE))" - return _SqlFragment(sql, start.columns | end.columns) - - def _translate_weekday(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("WEEKDAY requires a date argument") - if len(args) > 2: - raise DaxTranslationError("WEEKDAY supports at most date and return_type arguments") - date_value = self._translate_scalar(args[0]) - return_type = self._number_literal_value(args[1]) if len(args) > 1 else 1 - dow = f"EXTRACT(DOW FROM CAST({date_value.sql} AS DATE))" - if return_type == 1: - sql = f"({dow} + 1)" - elif return_type == 2: - sql = f"((({dow} + 6) % 7) + 1)" - elif return_type == 3: - sql = f"(({dow} + 6) % 7)" - elif return_type in (11, 12, 13, 14, 15, 16, 17): - start_dow = return_type - 10 - if start_dow == 7: - start_dow = 0 - sql = f"((({dow} - {start_dow} + 7) % 7) + 1)" - else: - raise DaxTranslationError("WEEKDAY return_type currently supports 1, 2, 3, or 11-17") - return _SqlFragment(sql, date_value.columns) - - def _translate_weeknum(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("WEEKNUM requires a date argument") - if len(args) > 2: - raise DaxTranslationError("WEEKNUM supports at most date and return_type arguments") - date_value = self._translate_scalar(args[0]) - return_type = self._number_literal_value(args[1]) if len(args) > 1 else 1 - if return_type == 1: - sql = f"(CAST(STRFTIME(CAST({date_value.sql} AS DATE), '%U') AS INTEGER) + 1)" - elif return_type == 2: - sql = f"(CAST(STRFTIME(CAST({date_value.sql} AS DATE), '%W') AS INTEGER) + 1)" - elif return_type == 21: - sql = f"CAST(STRFTIME(CAST({date_value.sql} AS DATE), '%V') AS INTEGER)" - else: - raise DaxTranslationError("WEEKNUM return_type currently supports 1, 2, or 21") - return _SqlFragment(sql, date_value.columns) - - def _translate_containsstring(self, args: list[Any], *, exact: bool) -> _SqlFragment: - if len(args) < 2: - func = "CONTAINSSTRINGEXACT" if exact else "CONTAINSSTRING" - raise DaxTranslationError(f"{func} requires text and search arguments") - if len(args) > 2: - func = "CONTAINSSTRINGEXACT" if exact else "CONTAINSSTRING" - raise DaxTranslationError(f"{func} supports exactly two arguments") - text = self._translate_scalar(args[0]) - search = self._translate_scalar(args[1]) - if exact: - sql = f"(POSITION({search.sql} IN {text.sql}) > 0)" - else: - sql = f"(POSITION(LOWER({search.sql}) IN LOWER({text.sql})) > 0)" - return _SqlFragment(sql, text.columns | search.columns) - - def _translate_containsrow(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("CONTAINSROW requires a table expression and at least one value argument") - - table_expr = self._unwrap(args[0]) - if self._table_name_from_expr(table_expr) is None and not isinstance( - table_expr, (self.dax.FunctionCall, self.dax.TableConstructor) - ): - raise DaxTranslationError("CONTAINSROW requires a table expression as first argument") - - table_sql = self._translate_table(table_expr) - table_width = self._treatas_source_width(table_expr, table_sql) - if table_width is None: - raise DaxTranslationError("CONTAINSROW requires an inferable table column count") - - value_exprs = args[1:] - if len(value_exprs) != table_width: - raise DaxTranslationError("CONTAINSROW value argument count must match table column count") - - value_fragments = [self._translate_scalar(expr) for expr in value_exprs] - alias_names = [f"c{idx + 1}" for idx in range(table_width)] - alias_list_sql = ", ".join(self._quote_identifier(alias) for alias in alias_names) - predicates = [ - f"t.{self._quote_identifier(alias_name)} IS NOT DISTINCT FROM {value_fragment.sql}" - for alias_name, value_fragment in zip(alias_names, value_fragments, strict=False) - ] - predicate_sql = " AND ".join(predicates) if predicates else "TRUE" - sql = f"EXISTS (SELECT 1 FROM ({table_sql}) AS t({alias_list_sql}) WHERE {predicate_sql})" - - columns = set(self._columns_from_sql(table_sql)) - for value_fragment in value_fragments: - columns.update(value_fragment.columns) - return _SqlFragment(sql, frozenset(columns)) - - def _translate_len(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("LEN requires an argument") - if len(args) > 1: - raise DaxTranslationError("LEN supports exactly one argument") - value = self._translate_scalar(args[0]) - return _SqlFragment(f"LENGTH({value.sql})", value.columns) - - def _translate_replace(self, args: list[Any]) -> _SqlFragment: - if len(args) < 4: - raise DaxTranslationError("REPLACE requires old_text, start_num, num_chars, and new_text arguments") - text = self._translate_scalar(args[0]) - start_num = self._translate_scalar(args[1]) - num_chars = self._translate_scalar(args[2]) - new_text = self._translate_scalar(args[3]) - safe_start = f"GREATEST({start_num.sql}, 1)" - safe_chars = f"GREATEST({num_chars.sql}, 0)" - sql = ( - f"(CASE WHEN {safe_start} <= 1 THEN '' ELSE SUBSTRING({text.sql}, 1, {safe_start} - 1) END " - f"|| {new_text.sql} || SUBSTRING({text.sql}, {safe_start} + {safe_chars}))" - ) - return _SqlFragment(sql, text.columns | start_num.columns | num_chars.columns | new_text.columns) - - def _translate_substitute(self, args: list[Any]) -> _SqlFragment: - if len(args) < 3: - raise DaxTranslationError("SUBSTITUTE requires text, old_text, and new_text arguments") - if len(args) > 4: - raise DaxTranslationError( - "SUBSTITUTE supports at most text, old_text, new_text, and instance_num arguments" - ) - text = self._translate_scalar(args[0]) - old_text = self._translate_scalar(args[1]) - new_text = self._translate_scalar(args[2]) - if len(args) == 3: - sql = f"REPLACE({text.sql}, {old_text.sql}, {new_text.sql})" - return _SqlFragment(sql, text.columns | old_text.columns | new_text.columns) - - instance_num = self._number_literal_value(args[3]) - if instance_num is not None: - if instance_num < 1: - raise DaxTranslationError("SUBSTITUTE instance_num must be >= 1") - - pos_sql = self._nth_occurrence_position_sql(text.sql, old_text.sql, instance_num) - sql = ( - f"CASE WHEN {old_text.sql} = '' THEN {text.sql} " - f"WHEN ({pos_sql}) = 0 THEN {text.sql} " - f"ELSE SUBSTR({text.sql}, 1, ({pos_sql}) - 1) || {new_text.sql} " - f"|| SUBSTR({text.sql}, ({pos_sql}) + LENGTH({old_text.sql})) END" - ) - return _SqlFragment(sql, text.columns | old_text.columns | new_text.columns) - - instance_fragment = self._translate_scalar(args[3]) - instance_sql = f"CAST(({instance_fragment.sql}) AS BIGINT)" - split_sql = f"STRING_SPLIT({text.sql}, {old_text.sql})" - occurrences_sql = f"(ARRAY_LENGTH({split_sql}) - 1)" - prefix_sql = f"ARRAY_TO_STRING(LIST_SLICE({split_sql}, 1, {instance_sql}), {old_text.sql})" - suffix_sql = ( - f"ARRAY_TO_STRING(LIST_SLICE({split_sql}, {instance_sql} + 1, ARRAY_LENGTH({split_sql})), {old_text.sql})" - ) - sql = ( - f"CASE WHEN {old_text.sql} = '' THEN {text.sql} " - f"WHEN {instance_sql} IS NULL OR {instance_sql} < 1 THEN {text.sql} " - f"WHEN {instance_sql} > {occurrences_sql} THEN {text.sql} " - f"ELSE {prefix_sql} || {new_text.sql} || {suffix_sql} END" - ) - return _SqlFragment(sql, text.columns | old_text.columns | new_text.columns | instance_fragment.columns) - - def _nth_occurrence_position_sql(self, text_sql: str, needle_sql: str, occurrence: int) -> str: - if occurrence < 1: - raise DaxTranslationError("SUBSTITUTE instance_num must be >= 1") - pos_sql = f"INSTR({text_sql}, {needle_sql})" - for _ in range(2, occurrence + 1): - next_sql = f"INSTR(SUBSTR({text_sql}, ({pos_sql}) + LENGTH({needle_sql})), {needle_sql})" - pos_sql = ( - f"CASE WHEN ({pos_sql}) = 0 THEN 0 " - f"WHEN ({next_sql}) = 0 THEN 0 " - f"ELSE ({next_sql}) + ({pos_sql}) + LENGTH({needle_sql}) - 1 END" - ) - return pos_sql - - def _translate_rept(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("REPT requires text and number_times arguments") - if len(args) > 2: - raise DaxTranslationError("REPT supports exactly two arguments") - text = self._translate_scalar(args[0]) - number_times = self._translate_scalar(args[1]) - safe_count = f"GREATEST(CAST(FLOOR({number_times.sql}) AS BIGINT), 0)" - return _SqlFragment(f"REPEAT({text.sql}, {safe_count})", text.columns | number_times.columns) - - def _translate_trim(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("TRIM requires an argument") - if len(args) > 1: - raise DaxTranslationError("TRIM supports exactly one argument") - value = self._translate_scalar(args[0]) - sql = f"TRIM(REGEXP_REPLACE(CAST({value.sql} AS VARCHAR), ' +', ' ', 'g'))" - return _SqlFragment(sql, value.columns) - - def _translate_left(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("LEFT requires a text argument") - if len(args) > 2: - raise DaxTranslationError("LEFT supports at most text and num_chars arguments") - text = self._translate_scalar(args[0]) - num_chars = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("1", frozenset()) - sql = f"SUBSTRING({text.sql}, 1, GREATEST({num_chars.sql}, 0))" - return _SqlFragment(sql, text.columns | num_chars.columns) - - def _translate_right(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("RIGHT requires a text argument") - if len(args) > 2: - raise DaxTranslationError("RIGHT supports at most text and num_chars arguments") - text = self._translate_scalar(args[0]) - num_chars = self._translate_scalar(args[1]) if len(args) > 1 else _SqlFragment("1", frozenset()) - sql = ( - f"CASE WHEN {num_chars.sql} <= 0 THEN '' " - f"ELSE SUBSTRING({text.sql}, GREATEST(LENGTH({text.sql}) - {num_chars.sql} + 1, 1), {num_chars.sql}) END" - ) - return _SqlFragment(sql, text.columns | num_chars.columns) - - def _translate_mid(self, args: list[Any]) -> _SqlFragment: - if len(args) != 3: - raise DaxTranslationError("MID requires text, start_num, and num_chars arguments") - text = self._translate_scalar(args[0]) - start_num = self._translate_scalar(args[1]) - num_chars = self._translate_scalar(args[2]) - sql = ( - f"CASE WHEN {num_chars.sql} <= 0 THEN '' " - f"ELSE SUBSTRING({text.sql}, GREATEST({start_num.sql}, 1), {num_chars.sql}) END" - ) - return _SqlFragment(sql, text.columns | start_num.columns | num_chars.columns) - - def _translate_find_search(self, args: list[Any], *, case_sensitive: bool, func: str) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError(f"{func} requires find_text and within_text arguments") - if len(args) > 4: - raise DaxTranslationError(f"{func} supports at most find_text, within_text, start_num, and not_found_value") - needle = self._translate_scalar(args[0]) - haystack = self._translate_scalar(args[1]) - start_num = self._translate_scalar(args[2]) if len(args) > 2 else None - not_found = self._translate_scalar(args[3]) if len(args) > 3 else None - - if case_sensitive: - needle_sql = needle.sql - haystack_sql = haystack.sql - else: - needle_sql = f"LOWER({needle.sql})" - haystack_sql = f"LOWER({haystack.sql})" - - if start_num is None: - base_pos = f"POSITION({needle_sql} IN {haystack_sql})" - adjusted = base_pos - else: - segment = f"SUBSTRING({haystack_sql}, {start_num.sql})" - base_pos = f"POSITION({needle_sql} IN {segment})" - adjusted = f"CASE WHEN {base_pos} = 0 THEN 0 ELSE ({base_pos} + {start_num.sql} - 1) END" - - if not_found is not None: - sql = f"CASE WHEN {adjusted} = 0 THEN {not_found.sql} ELSE {adjusted} END" - else: - sql = adjusted - - columns = set(needle.columns) | set(haystack.columns) - if start_num is not None: - columns.update(start_num.columns) - if not_found is not None: - columns.update(not_found.columns) - return _SqlFragment(sql, frozenset(columns)) - - def _translate_date_part(self, args: list[Any], *, part: str) -> _SqlFragment: - if not args: - raise DaxTranslationError(f"{part.upper()} requires an argument") - if len(args) > 1: - raise DaxTranslationError(f"{part.upper()} supports exactly one argument") - value = self._translate_scalar(args[0]) - part_map = { - "year": "YEAR", - "month": "MONTH", - "day": "DAY", - "hour": "HOUR", - "minute": "MINUTE", - "second": "SECOND", - "quarter": "QUARTER", - } - part_sql = part_map[part] - cast_type = "TIMESTAMP" if part in ("hour", "minute", "second") else "DATE" - sql = f"EXTRACT({part_sql} FROM CAST({value.sql} AS {cast_type}))" - return _SqlFragment(sql, value.columns) - - def _translate_exact(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("EXACT requires two arguments") - if len(args) > 2: - raise DaxTranslationError("EXACT supports exactly two arguments") - left = self._translate_scalar(args[0]) - right = self._translate_scalar(args[1]) - return _SqlFragment(f"({left.sql} = {right.sql})", left.columns | right.columns) - - def _translate_randbetween(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("RANDBETWEEN requires bottom and top arguments") - if len(args) > 2: - raise DaxTranslationError("RANDBETWEEN supports exactly two arguments") - lower = self._translate_scalar(args[0]) - upper = self._translate_scalar(args[1]) - sql = f"CAST(FLOOR(RANDOM() * (({upper.sql}) - ({lower.sql}) + 1) + ({lower.sql})) AS BIGINT)" - return _SqlFragment(sql, lower.columns | upper.columns) - - def _translate_format(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("FORMAT requires value and format_string arguments") - if len(args) > 3: - raise DaxTranslationError("FORMAT supports at most value, format_string, and locale arguments") - value = self._translate_scalar(args[0]) - # Current lowering preserves value-to-text behavior and ignores format mask semantics. - return _SqlFragment(f"CAST({value.sql} AS VARCHAR)", value.columns) - - def _translate_iferror(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("IFERROR requires value and value_if_error arguments") - if len(args) > 2: - raise DaxTranslationError("IFERROR supports exactly two arguments") - value = self._translate_scalar(args[0]) - fallback = self._translate_scalar(args[1]) - sql = f"CASE WHEN {value.sql} IS NULL THEN {fallback.sql} ELSE {value.sql} END" - return _SqlFragment(sql, value.columns | fallback.columns) - - def _translate_isinscope(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("ISINSCOPE requires an argument") - if len(args) > 1: - raise DaxTranslationError("ISINSCOPE supports exactly one argument") - target = self._translate_scalar(args[0]) - in_scope = self._any_column_in_context(target.columns, self._current_group_by_columns) - return _SqlFragment("TRUE" if in_scope else "FALSE", target.columns) - - def _translate_isfiltered(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("ISFILTERED requires an argument") - if len(args) > 1: - raise DaxTranslationError("ISFILTERED/ISCROSSFILTERED supports exactly one argument") - target = self._translate_scalar(args[0]) - is_filtered = self._any_column_in_context(target.columns, self._current_filter_columns) - return _SqlFragment("TRUE" if is_filtered else "FALSE", target.columns) - - def _translate_isempty(self, args: list[Any]) -> _SqlFragment: - if not args: - raise DaxTranslationError("ISEMPTY requires a table argument") - if len(args) > 1: - raise DaxTranslationError("ISEMPTY supports exactly one argument") - with self._allow_cross_table_context(): - table_sql = self._translate_table(args[0]) - return _SqlFragment( - f"NOT EXISTS (SELECT 1 FROM ({table_sql}) AS t)", frozenset(self._columns_from_sql(table_sql)) - ) - - @staticmethod - def _any_column_in_context(target_cols: frozenset[ColumnRef], context_cols: frozenset[ColumnRef]) -> bool: - for target in target_cols: - for context in context_cols: - if _columns_match(target, context): - return True - return False - - def _translate_switch(self, args: list[Any]) -> _SqlFragment: - if len(args) < 3: - raise DaxTranslationError("SWITCH requires expression and at least one value/result pair") - - first = self._translate_scalar(args[0]) - pairs = args[1:] - - is_boolean_switch = self._is_true_literal(args[0]) - when_clauses = [] - columns = set(first.columns) - - idx = 0 - while idx + 1 < len(pairs): - cond_expr = pairs[idx] - result_expr = pairs[idx + 1] - cond = self._translate_scalar(cond_expr) - result = self._translate_scalar(result_expr) - if is_boolean_switch: - when_sql = cond.sql - else: - when_sql = f"{first.sql} = {cond.sql}" - when_clauses.append(f"WHEN {when_sql} THEN {result.sql}") - columns.update(cond.columns) - columns.update(result.columns) - idx += 2 - - else_expr = None - if idx < len(pairs): - else_expr = self._translate_scalar(pairs[idx]) - columns.update(else_expr.columns) - - else_sql = else_expr.sql if else_expr else "NULL" - sql = f"CASE {' '.join(when_clauses)} ELSE {else_sql} END" - return _SqlFragment(sql, frozenset(columns)) - - def _translate_coalesce(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("COALESCE requires at least two arguments") - fragments = [self._translate_scalar(arg) for arg in args] - sql = f"COALESCE({', '.join(f.sql for f in fragments)})" - columns = set() - for frag in fragments: - columns.update(frag.columns) - return _SqlFragment(sql, frozenset(columns)) - - def _translate_divide(self, args: list[Any]) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError("DIVIDE requires numerator and denominator") - if len(args) > 3: - raise DaxTranslationError("DIVIDE supports at most numerator, denominator, and alternate result arguments") - numerator = self._translate_scalar(args[0]) - denominator = self._translate_scalar(args[1]) - alternate = self._translate_scalar(args[2]) if len(args) > 2 else _SqlFragment("NULL", frozenset()) - sql = ( - f"CASE WHEN {denominator.sql} IS NULL OR {denominator.sql} = 0 " - f"THEN {alternate.sql} ELSE {numerator.sql} / {denominator.sql} END" - ) - columns = numerator.columns | denominator.columns | alternate.columns - return _SqlFragment(sql, frozenset(columns)) - - def _translate_and_or(self, args: list[Any], op: str) -> _SqlFragment: - if len(args) < 2: - raise DaxTranslationError(f"{op} requires two arguments") - if len(args) > 2: - raise DaxTranslationError(f"{op} supports exactly two arguments") - left = self._translate_scalar(args[0]) - right = self._translate_scalar(args[1]) - sql = f"({left.sql} {op} {right.sql})" - return _SqlFragment(sql, left.columns | right.columns) - - def _translate_projection_scalar(self, expr: Any) -> _SqlFragment: - try: - return self._translate_scalar(expr) - except DaxTranslationError as exc: - if str(exc) != "DAX table expressions must reference a single base table": - raise - - with self._allow_cross_table_context(): - with self._prefer_unqualified_base_table_context(): - return self._translate_scalar(expr) - - def _translate_in_list(self, expr: Any) -> str: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.TableConstructor): - if not expr.rows: - return "(NULL)" - row_sqls = [] - for row in expr.rows: - if len(row) == 1: - row_sqls.append(self._translate_scalar(row[0]).sql) - else: - row_sqls.append("(" + ", ".join(self._translate_scalar(item).sql for item in row) + ")") - return f"({', '.join(row_sqls)})" - raise DaxTranslationError("IN requires a table constructor") - - def _translate_identifier(self, name: str) -> _SqlFragment: - env_key = name.lower() - if env_key in self._env: - return self._env[env_key] - - if self.model_name is None: - metric = self._translate_metric_reference_name(name) - if metric is not None: - return self._metric_to_scalar_fragment(metric) - elif self._is_measure_name(name): - return _SqlFragment(self._quote_identifier(name), frozenset()) - - if self.model_name is None and self._base_table is None: - raise DaxTranslationError(f"Ambiguous identifier '{name}' without a table context") - - table_name = self.model_name or self._base_table - column_sql = self._column_sql(table_name, name) - columns = frozenset({ColumnRef(table_name, name)}) - return _SqlFragment(column_sql, columns) - - def _translate_table_column(self, table: str, column: str) -> _SqlFragment: - self._ensure_table_context(table) - column_sql = self._column_sql(table, column) - columns = frozenset({ColumnRef(table, column)}) - return _SqlFragment(column_sql, columns) - - def _column_sql(self, table: str | None, column: str) -> str: - if table is not None and self.model_name is not None: - if table.lower() != self.model_name.lower(): - if not self._allow_cross_table: - raise DaxTranslationError( - f"DAX translation only supports references to '{self.model_name}', found '{table}'" - ) - self._required_models.add(table) - elif table is not None and self.model_name is None: - self._required_models.add(table) - - table_key = table or self.model_name or "" - column_map = self.column_sql_by_table.get(table_key, {}) - mapped = column_map.get(column) - if mapped is None: - mapped = self._quote_identifier(column) - - if table is None: - return mapped - if self.model_name and table.lower() == self.model_name.lower(): - return mapped - # In table-query translation (no explicit model), same-table references are rendered - # unqualified so wrapped table expressions remain valid (`FROM () AS t`). - if ( - self.model_name is None - and self._base_table is not None - and table.lower() == self._base_table.lower() - and (not self._allow_cross_table or self._prefer_unqualified_base_table) - ): - return mapped - - table_sql = self._quote_identifier(table) - if _can_qualify_identifier(mapped): - return f"{table_sql}.{mapped}" - return mapped - - def _table_sql(self, table: str) -> str: - return self._quote_identifier(table) - - def _default_table_sql(self) -> str: - if self.model_name: - return self._table_sql(self.model_name) - if self._base_table: - return self._table_sql(self._base_table) - raise DaxTranslationError("No default table context for DAX table expression") - - def _is_measure_name(self, name: str) -> bool: - return self._resolve_measure_reference(name) is not None - - def _resolve_measure_reference(self, measure: str) -> tuple[str, str] | None: - if "." in measure: - table, name = measure.split(".", 1) - return table, name - - if self.model_name: - return self.model_name, measure - - candidates: list[tuple[str, str]] = [] - measure_lower = measure.lower() - for table, measure_names in self.measure_names_by_table.items(): - if measure in measure_names: - candidates.append((table, measure)) - continue - for known in measure_names: - if known.lower() == measure_lower: - candidates.append((table, known)) - break - if len(candidates) == 1: - return candidates[0] - return None - - def _lookup_measure_agg(self, measure: str) -> str | None: - resolved = self._resolve_measure_reference(measure) - if resolved is None: - return None - table, name = resolved - return self.measure_aggs_by_table.get(table, {}).get(name) - - def _lookup_measure_sql(self, measure: str) -> str | None: - resolved = self._resolve_measure_reference(measure) - if resolved is None: - return None - table, name = resolved - return self.measure_sql_by_table.get(table, {}).get(name) - - def _translate_metric_reference(self, expr: Any) -> MetricTranslation | None: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.TableColumnRef): - return self._translate_metric_reference_name(expr.column, table=expr.table.name) - if isinstance(expr, self.dax.BracketRef): - return self._translate_metric_reference_name(expr.name) - if isinstance(expr, self.dax.Identifier): - return self._translate_metric_reference_name(expr.name) - return None - - def _translate_metric_reference_name(self, name: str, table: str | None = None) -> MetricTranslation | None: - if table is not None: - resolved = self._resolve_measure_reference_for_table(table, name) - else: - resolved = self._resolve_measure_reference(name) - if resolved is None: - return None - - table, measure = resolved - agg = self.measure_aggs_by_table.get(table, {}).get(measure) - sql = self.measure_sql_by_table.get(table, {}).get(measure) - filters = list(self.measure_filters_by_table.get(table, {}).get(measure, [])) - if agg is None and sql is None: - return MetricTranslation(sql=self._quote_identifier(measure), type="derived") - - if sql: - sql = self._measure_sql_for_context(table, sql) - elif agg: - sql = f"{table}.{measure}" if self.model_name is not None else None - - return MetricTranslation( - sql=sql, - agg=agg, - type=None if agg else "derived", - source_table=table, - filters=filters, - ) - - def _resolve_measure_reference_for_table(self, table: str, measure: str) -> tuple[str, str] | None: - table_lower = table.lower() - measure_lower = measure.lower() - for known_table, measure_names in self.measure_names_by_table.items(): - if known_table.lower() != table_lower: - continue - for known_measure in measure_names: - if known_measure == measure or known_measure.lower() == measure_lower: - return known_table, known_measure - return None - - def _measure_sql_for_context(self, table: str, sql: str) -> str: - if self.model_name is not None: - if table.lower() == self.model_name.lower(): - return sql - self._required_models.add(table) - return self._qualify_measure_sql(table, sql) - - def _qualify_measure_sql(self, table: str, sql: str | None) -> str | None: - if not sql: - return sql - - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - for column in parsed.find_all(exp.Column): - if column.table: - continue - column.set("table", exp.to_identifier(table)) - return parsed.sql(dialect="duckdb") - except Exception: - if _can_qualify_identifier(sql): - return f"{self._table_sql(table)}.{sql}" - return sql - - def _unwrap(self, expr: Any) -> Any: - while isinstance(expr, self.dax.Paren): - expr = expr.expr - return expr - - def _table_name_from_expr(self, expr: Any) -> str | None: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.TableRef): - return expr.table.name - if isinstance(expr, self.dax.Identifier): - if self._is_known_measure_identifier(expr.name): - return None - if not self._table_exists(expr.name): - return None - return expr.name - if isinstance(expr, self.dax.FunctionCall) and expr.name.lower() == "relatedtable" and expr.args: - return self._table_name_from_expr(expr.args[0]) - return None - - def _with_vars(self, var_block: Any, func, body: Any): - prior = dict(self._env) - for decl in var_block.decls: - value = self._translate_scalar(decl.expr) - self._env[decl.name.lower()] = value - result = func(body) - self._env = prior - return result - - def _extract_measure_reference(self, expr: Any) -> str | None: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.BracketRef): - return self._qualify_measure(expr.name) - if isinstance(expr, self.dax.Identifier): - return self._qualify_measure(expr.name) - return None - - def _qualify_measure(self, name: str) -> str: - resolved = self._resolve_measure_reference(name) - if resolved is not None: - table, measure = resolved - return f"{table}.{measure}" - return name - - def _ensure_table_context(self, table: str | None) -> None: - if table is None: - return - if self.model_name: - if table.lower() != self.model_name.lower(): - if self._allow_cross_table: - self._required_models.add(table) - return - raise DaxTranslationError( - f"DAX translation only supports references to '{self.model_name}', found '{table}'" - ) - return - if self._base_table is None: - self._base_table = table - return - if table.lower() != self._base_table.lower(): - if self._allow_cross_table: - self._required_models.add(table) - return - raise DaxTranslationError("DAX table expressions must reference a single base table") - - def _append_tables(self, ordered_tables: list[str], seen_tables: set[str], columns: Iterable[ColumnRef]) -> None: - for column in columns: - if not column.table: - continue - key = column.table.lower() - if key in seen_tables: - continue - ordered_tables.append(column.table) - seen_tables.add(key) - - def _tables_for_scalar_fragment(self, fragment: _SqlFragment) -> list[str]: - referenced_tables: dict[str, str] = {} - for column in fragment.columns: - if not column.table: - continue - key = column.table.lower() - referenced_tables.setdefault(key, column.table) - - tables: list[str] = [] - if self._base_table and self._base_table.lower() in referenced_tables: - tables.append(referenced_tables.pop(self._base_table.lower())) - for _, table_name in sorted(referenced_tables.items(), key=lambda item: item[0]): - tables.append(table_name) - return tables - - def _scalar_fragment_sql_with_from(self, fragment: _SqlFragment) -> str: - tables = self._tables_for_scalar_fragment(fragment) - if not tables: - return fragment.sql - from_clause = self._build_from_clause_for_tables(tables) - return f"(SELECT {fragment.sql} FROM {from_clause})" - - def _build_relationship_adjacency(self, edges: list[RelationshipEdge]) -> dict[str, list[tuple[str, str, str]]]: - adjacency: dict[str, list[tuple[str, str, str]]] = {} - for edge in edges: - from_table = edge.from_table - to_table = edge.to_table - adjacency.setdefault(from_table, []).append((to_table, edge.from_column, edge.to_column)) - adjacency.setdefault(to_table, []).append((from_table, edge.to_column, edge.from_column)) - return adjacency - - def _find_relationship_path(self, base_table: str, target_table: str) -> list[tuple[str, str, str, str]] | None: - if base_table == target_table: - return [] - - visited = {base_table} - queue = deque([base_table]) - parent: dict[str, tuple[str, str, str]] = {} - - while queue: - current = queue.popleft() - for next_table, current_col, next_col in self._relationship_adjacency.get(current, []): - if next_table in visited: - continue - visited.add(next_table) - parent[next_table] = (current, current_col, next_col) - if next_table == target_table: - path: list[tuple[str, str, str, str]] = [] - node = target_table - while node != base_table: - prev, prev_col, node_col = parent[node] - path.append((prev, node, prev_col, node_col)) - node = prev - path.reverse() - return path - queue.append(next_table) - - return None - - def _find_relationship_path_from_joined( - self, joined_tables: list[str], target_table: str - ) -> list[tuple[str, str, str, str]] | None: - best_path: list[tuple[str, str, str, str]] | None = None - for anchor in joined_tables: - path = self._find_relationship_path(anchor, target_table) - if path is None: - continue - if best_path is None or len(path) < len(best_path): - best_path = path - return best_path - - def _build_from_clause_for_tables(self, tables_in_order: list[str]) -> str: - if not tables_in_order: - return self._default_table_sql() - - base_table = tables_in_order[0] - from_parts = [self._table_sql(base_table)] - joined_tables = {base_table.lower()} - joined_order = [base_table] - - for table in tables_in_order[1:]: - table_key = table.lower() - if table_key in joined_tables: - continue - path = self._find_relationship_path_from_joined(joined_order, table) - if path is None: - if self._allow_unrelated_table_cross_join: - self._append_unrelated_cross_join_warning(base_table, table) - from_parts.append(f"CROSS JOIN {self._table_sql(table)}") - joined_tables.add(table_key) - joined_order.append(table) - continue - raise DaxTranslationError(f"No relationship path between {base_table} and {table}") - for from_table, to_table, from_col, to_col in path: - to_key = to_table.lower() - if to_key in joined_tables: - continue - left_table = self._table_sql(from_table) - right_table = self._table_sql(to_table) - from_col_sql = self._quote_identifier(from_col) - to_col_sql = self._quote_identifier(to_col) - from_parts.append( - f"LEFT JOIN {right_table} ON {left_table}.{from_col_sql} = {right_table}.{to_col_sql}" - ) - joined_tables.add(to_key) - joined_order.append(to_table) - - return " ".join(from_parts) - - def _append_unrelated_cross_join_warning(self, base_table: str, table: str) -> None: - key = ("dax_unrelated_cross_join", base_table.lower(), table.lower()) - if key in self._warning_keys: - return - self._warning_keys.add(key) - self._warnings.append( - { - "code": "dax_unrelated_cross_join", - "context": "query", - "base_table": base_table, - "table": table, - "message": ( - f"DAX query cross joins unrelated table '{table}' with '{base_table}' " - "because no relationship path is defined" - ), - } - ) - - def _validate_time_argument(self, expr: Any | None) -> None: - if not expr: - return - candidate = self._unwrap(expr) - if isinstance(candidate, self.dax.HierarchyRef): - table = candidate.table.name - column = candidate.levels[-1] if candidate.levels else candidate.column - self._validate_time_dimension(table, column) - return - if isinstance(candidate, self.dax.TableColumnRef): - self._validate_time_dimension(candidate.table.name, candidate.column) - return - if self.time_dimensions_by_table: - raise DaxTranslationError("Time intelligence requires a table time column argument") - - def _validate_time_dimension(self, table: str | None, column: str) -> None: - if not self.time_dimensions_by_table: - return - table_name = table or self.model_name - if not table_name: - return - known_dims = self.time_dimensions_by_table.get(table_name) - if known_dims is None: - known_dims = set() - for key, value in self.time_dimensions_by_table.items(): - if key.lower() == table_name.lower(): - known_dims = value - break - if known_dims and column in known_dims: - return - raise DaxTranslationError(f"{table_name}[{column}] is not a known time dimension") - - def _allow_cross_table_context(self): - class _Context: - def __init__(self, outer): - self.outer = outer - self.prior = outer._allow_cross_table - - def __enter__(self): - self.outer._allow_cross_table = True - return self - - def __exit__(self, exc_type, exc, tb): - self.outer._allow_cross_table = self.prior - return False - - return _Context(self) - - def _prefer_unqualified_base_table_context(self): - class _Context: - def __init__(self, outer): - self.outer = outer - self.prior = outer._prefer_unqualified_base_table - - def __enter__(self): - self.outer._prefer_unqualified_base_table = True - return self - - def __exit__(self, exc_type, exc, tb): - self.outer._prefer_unqualified_base_table = self.prior - return False - - return _Context(self) - - def _measure_eval_context(self, group_by_cols: set[ColumnRef], filter_cols: set[ColumnRef]): - class _Context: - def __init__(self, outer): - self.outer = outer - self.prior_group = outer._current_group_by_columns - self.prior_filter = outer._current_filter_columns - self.group = frozenset(group_by_cols) - self.filters = frozenset(filter_cols) - - def __enter__(self): - self.outer._current_group_by_columns = self.group - self.outer._current_filter_columns = self.filters - return self - - def __exit__(self, exc_type, exc, tb): - self.outer._current_group_by_columns = self.prior_group - self.outer._current_filter_columns = self.prior_filter - return False - - return _Context(self) - - def _parse_dateadd(self, call: Any) -> tuple[int, str] | None: - if len(call.args) < 3: - return None - offset = self._number_literal_value(call.args[1]) - unit = self._identifier_literal_value(call.args[2]) - if offset is None or unit is None: - return None - normalized_unit = unit.lower() - if normalized_unit.endswith("s"): - normalized_unit = normalized_unit[:-1] - # DAX DATEADD direction is inverse of row-offset semantics used by - # window LAG/LEAD generation: -1 YEAR means prior period (lag +1). - return -offset, normalized_unit - - def _string_literal_value(self, expr: Any) -> str | None: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.String): - return expr.value - return None - - def _identifier_literal_value(self, expr: Any) -> str | None: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.Identifier): - return expr.name - if isinstance(expr, self.dax.String): - return expr.value - return None - - def _number_literal_value(self, expr: Any) -> int | None: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.Number): - try: - return int(float(expr.value)) - except ValueError: - return None - if isinstance(expr, self.dax.Unary): - inner = self._number_literal_value(expr.expr) - if inner is None: - return None - op_name = getattr(expr.op, "name", str(expr.op)).lower() - if "minus" in op_name: - return -inner - if "plus" in op_name: - return inner - return None - return None - - def _topn_numeric_arg_sql(self, expr: Any, *, function_name: str, arg_name: str) -> str: - literal_value = self._number_literal_value(expr) - if literal_value is not None: - return str(literal_value) - - try: - with self._allow_cross_table_context(): - fragment = self._translate_scalar(expr) - except DaxTranslationError as exc: - raise DaxTranslationError(f"{function_name} {arg_name} must be a number") from exc - - if fragment.columns: - raise DaxTranslationError(f"{function_name} {arg_name} must be a number") - return f"CAST(({fragment.sql}) AS BIGINT)" - - def _is_true_literal(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.Boolean): - return expr.value is True - if isinstance(expr, self.dax.FunctionCall): - return expr.name.lower() == "true" and not expr.args - if isinstance(expr, self.dax.Identifier) and expr.name.lower() == "true": - return True - return False - - def _is_blank_expr(self, expr: Any) -> bool: - expr = self._unwrap(expr) - if isinstance(expr, self.dax.Blank): - return True - if isinstance(expr, self.dax.FunctionCall): - return expr.name.lower() == "blank" and not expr.args - if isinstance(expr, self.dax.Identifier): - return expr.name.lower() == "blank" - return False - - @staticmethod - def _quote_string(value: str) -> str: - return "'" + value.replace("'", "''") + "'" - - @staticmethod - def _quote_identifier(name: str) -> str: - if _is_safe_identifier(name): - return name - escaped = name.replace('"', '""') - return f'"{escaped}"' - - -@dataclass(frozen=True) -class _SqlFragment: - sql: str - columns: frozenset[ColumnRef] - - def wrap(self, sql: str) -> _SqlFragment: - return _SqlFragment(sql, self.columns) - - -@dataclass(frozen=True) -class _OrderKeySql: - direct_expr_sql: str - direct_order_sql: str - wrapped_order_sql: str - wrapped_ref_sql: str - direction: str - - -class _DefineResolver: - _AMBIGUOUS = object() - - def __init__(self, dax_ast: Any, define_block: Any | None) -> None: - self.dax = dax_ast - self._measure_defs: dict[str, Any] = {} - self._table_defs: dict[str, Any] = {} - self._var_defs: dict[str, Any] = {} - self._function_defs: dict[str, Any] = {} - self._column_defs: dict[tuple[str | None, str], Any] = {} - self._column_defs_by_name: dict[str, Any] = {} - - if define_block is None: - return - - for definition in define_block.defs: - if isinstance(definition, self.dax.MeasureDef): - self._measure_defs[definition.name.lower()] = definition.expr - elif isinstance(definition, self.dax.TableDef): - self._table_defs[definition.name.lower()] = definition.expr - elif isinstance(definition, self.dax.VarDef): - self._var_defs[definition.name.lower()] = definition.expr - elif isinstance(definition, self.dax.FunctionDef): - self._function_defs[definition.name.lower()] = definition - elif isinstance(definition, self.dax.ColumnDef): - table_name = definition.table.name.lower() if definition.table else None - col_name = definition.name.lower() - self._column_defs[(table_name, col_name)] = definition.expr - current = self._column_defs_by_name.get(col_name) - if current is None: - self._column_defs_by_name[col_name] = definition.expr - else: - self._column_defs_by_name[col_name] = self._AMBIGUOUS - - def resolve_expr(self, expr: Any) -> Any: - return self._resolve(expr, stack=(), bindings={}) - - def _resolve( - self, - expr: Any, - stack: tuple[tuple[str, str], ...], - bindings: dict[str, Any], - ) -> Any: - if isinstance(expr, self.dax.Paren): - return self.dax.Paren(expr=self._resolve(expr.expr, stack, bindings)) - - if isinstance(expr, self.dax.BracketRef): - key = ("measure", expr.name.lower()) - target = self._measure_defs.get(key[1]) - if target is not None: - if key in stack: - raise DaxTranslationError(f"Cyclic DEFINE MEASURE reference for [{expr.name}]") - return self.dax.Paren(self._resolve(target, stack + (key,), bindings)) - column_target = self._column_defs_by_name.get(expr.name.lower()) - if column_target is self._AMBIGUOUS: - return expr - if column_target is not None: - column_key = ("column", expr.name.lower()) - if column_key in stack: - raise DaxTranslationError(f"Cyclic DEFINE COLUMN reference for [{expr.name}]") - return self.dax.Paren(self._resolve(column_target, stack + (column_key,), bindings)) - return expr - - if isinstance(expr, self.dax.TableRef): - key = ("table", expr.table.name.lower()) - target = self._table_defs.get(key[1]) - if target is None: - return expr - if key in stack: - raise DaxTranslationError(f"Cyclic DEFINE TABLE reference for {expr.table.name}") - return self._resolve(target, stack + (key,), bindings) - - if isinstance(expr, self.dax.TableColumnRef): - key_name = (expr.table.name.lower(), expr.column.lower()) - target = self._column_defs.get(key_name) - if target is None: - return expr - key = ("column", f"{expr.table.name.lower()}.{expr.column.lower()}") - if key in stack: - raise DaxTranslationError(f"Cyclic DEFINE COLUMN reference for {expr.table.name}[{expr.column}]") - return self.dax.Paren(self._resolve(target, stack + (key,), bindings)) - - if isinstance(expr, self.dax.Identifier): - bound = bindings.get(expr.name.lower()) - if bound is not None: - return bound - key = ("var", expr.name.lower()) - target = self._var_defs.get(key[1]) - if target is not None: - if key in stack: - raise DaxTranslationError(f"Cyclic DEFINE VAR reference for {expr.name}") - return self.dax.Paren(self._resolve(target, stack + (key,), bindings)) - - table_target = self._table_defs.get(expr.name.lower()) - if table_target is not None: - table_key = ("table", expr.name.lower()) - if table_key in stack: - raise DaxTranslationError(f"Cyclic DEFINE TABLE reference for {expr.name}") - return self._resolve(table_target, stack + (table_key,), bindings) - - return expr - - if isinstance(expr, self.dax.FunctionCall): - resolved_args = [self._resolve(arg, stack, bindings) for arg in expr.args] - function_def = self._function_defs.get(expr.name.lower()) - if function_def is None: - return self.dax.FunctionCall(name=expr.name, args=resolved_args) - - if len(resolved_args) != len(function_def.params): - raise DaxTranslationError( - f"DEFINE FUNCTION {function_def.name} expects {len(function_def.params)} args, got {len(resolved_args)}" - ) - - key = ("function", function_def.name.lower()) - if key in stack: - raise DaxTranslationError(f"Cyclic DEFINE FUNCTION reference for {function_def.name}") - - scoped_bindings = dict(bindings) - for param, arg in zip(function_def.params, resolved_args, strict=False): - scoped_bindings[param.name.lower()] = arg - - return self.dax.Paren(self._resolve(function_def.body, stack + (key,), scoped_bindings)) - - if isinstance(expr, self.dax.Unary): - return self.dax.Unary(op=expr.op, expr=self._resolve(expr.expr, stack, bindings)) - - if isinstance(expr, self.dax.Binary): - return self.dax.Binary( - op=expr.op, - left=self._resolve(expr.left, stack, bindings), - right=self._resolve(expr.right, stack, bindings), - ) - - if isinstance(expr, self.dax.VarBlock): - scoped_bindings = dict(bindings) - decls = [] - for decl in expr.decls: - resolved_decl = self._resolve(decl.expr, stack, scoped_bindings) - decls.append(self.dax.VarDecl(name=decl.name, expr=resolved_decl)) - scoped_bindings[decl.name.lower()] = resolved_decl - body = self._resolve(expr.body, stack, scoped_bindings) - return self.dax.VarBlock(decls=decls, body=body) - - if isinstance(expr, self.dax.TableConstructor): - rows = [[self._resolve(value, stack, bindings) for value in row] for row in expr.rows] - return self.dax.TableConstructor(rows=rows) - - return expr - - -def _translate_order_keys(stmt: Any, translator: _DaxTranslator, resolver: _DefineResolver) -> list[_OrderKeySql]: - order_keys: list[_OrderKeySql] = [] - for key in stmt.order_by: - resolved_expr = resolver.resolve_expr(key.expr) - with translator._allow_cross_table_context(): - fragment = translator._translate_scalar(resolved_expr) - - direction = "ASC" if key.direction == translator.dax.SortDirection.asc else "DESC" - direct_order_sql = f"{fragment.sql} {direction}" - - wrapped_expr_sql = _rewrite_order_expr_for_wrapped(fragment.sql) - wrapped_ref_sql = wrapped_expr_sql - wrapped_order_sql = f"{wrapped_expr_sql} {direction}" - - order_keys.append( - _OrderKeySql( - direct_expr_sql=fragment.sql, - direct_order_sql=direct_order_sql, - wrapped_order_sql=wrapped_order_sql, - wrapped_ref_sql=wrapped_ref_sql, - direction=direction, - ) - ) - - return order_keys - - -def _apply_order_and_start_at( - base_sql: str, - stmt: Any, - translator: _DaxTranslator, - resolver: _DefineResolver, - order_keys: list[_OrderKeySql], -) -> str: - if stmt.start_at: - if not order_keys: - raise DaxTranslationError("START AT requires ORDER BY") - - if len(stmt.start_at) > len(order_keys): - raise DaxTranslationError("START AT has more arguments than ORDER BY") - - value_sql: list[str] = [] - for value in stmt.start_at: - resolved = resolver.resolve_expr(value) - value_sql.append(translator._translate_scalar(resolved).sql) - - start_order_keys = order_keys[: len(value_sql)] - predicate = _build_start_at_predicate(start_order_keys, value_sql) - sql = f"SELECT * FROM ({base_sql}) AS q WHERE {predicate}" - wrapped_order = [key.wrapped_order_sql for key in order_keys] - return f"{sql} ORDER BY {', '.join(wrapped_order)}" - - if not order_keys: - return base_sql - - wrapped_order = [key.wrapped_order_sql for key in order_keys] - return f"SELECT * FROM ({base_sql}) AS q ORDER BY {', '.join(wrapped_order)}" - - -def _build_start_at_predicate(order_keys: list[_OrderKeySql], start_values_sql: list[str]) -> str: - disjuncts: list[str] = [] - for idx, value_sql in enumerate(start_values_sql): - conjuncts: list[str] = [] - for prev_idx in range(idx): - prev_ref = order_keys[prev_idx].wrapped_ref_sql - conjuncts.append(f"{prev_ref} = {start_values_sql[prev_idx]}") - - ref = order_keys[idx].wrapped_ref_sql - - is_last = idx == len(start_values_sql) - 1 - direction = order_keys[idx].direction - if direction == "ASC": - op = ">=" if is_last else ">" - else: - op = "<=" if is_last else "<" - conjuncts.append(f"{ref} {op} {value_sql}") - disjuncts.append("(" + " AND ".join(conjuncts) + ")") - - return " OR ".join(disjuncts) - - -def _rewrite_order_expr_for_wrapped(sql: str) -> str: - return _rewrite_expr_for_alias(sql, "q") - - -def _rewrite_expr_for_alias( - sql: str, - alias: str, - source_tables: set[str] | None = None, - source_columns: set[str] | None = None, - source_column_aliases: dict[str, str] | None = None, - ambiguous_source_aliases: set[str] | None = None, - local_columns: set[str] | None = None, - ambiguous_source_columns: set[str] | None = None, - allow_fallback: bool = True, - strict_source_resolution: bool = False, -) -> str: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - source_tables_lower = {table.lower() for table in (source_tables or set())} - source_columns_lower = {column.lower() for column in (source_columns or set())} - ambiguous_source_aliases_lower = {name.lower() for name in (ambiguous_source_aliases or set())} - local_columns_lower = {column.lower() for column in (local_columns or set())} - ambiguous_source_columns_lower = {column.lower() for column in (ambiguous_source_columns or set())} - source_has_wildcard = "*" in source_columns_lower - source_has_multiple_tables = len(source_tables_lower) > 1 - source_aliases = source_column_aliases or {} - for column in parsed.find_all(exp.Column): - replacement_name: str | None = None - if source_tables_lower: - table_name = column.table - if table_name: - if table_name.lower() not in source_tables_lower: - continue - if _column_table_is_local_to_select(column): - continue - qualified_key = f"{table_name.lower()}.{column.name.lower()}" - if ( - qualified_key in ambiguous_source_aliases_lower - or column.name.lower() in ambiguous_source_aliases_lower - ): - if strict_source_resolution: - raise DaxTranslationError( - f"Ambiguous outer column reference '{table_name}.{column.name}' for alias '{alias}'" - ) - continue - replacement_name = source_aliases.get(qualified_key) or source_aliases.get(column.name.lower()) - if ( - replacement_name is None - and not source_has_wildcard - and column.name.lower() not in source_columns_lower - ): - continue - if replacement_name is None and source_has_wildcard and source_has_multiple_tables: - if strict_source_resolution: - raise DaxTranslationError( - f"Ambiguous outer column reference '{table_name}.{column.name}' for alias '{alias}'" - ) - continue - if replacement_name is None and column.name.lower() in ambiguous_source_columns_lower: - if strict_source_resolution: - raise DaxTranslationError( - f"Ambiguous outer column reference '{column.name}' for alias '{alias}'" - ) - continue - elif source_columns_lower: - column_key = column.name.lower() - if _column_name_is_local_to_select(column, local_columns_lower): - continue - if column_key in ambiguous_source_aliases_lower: - if strict_source_resolution: - raise DaxTranslationError( - f"Ambiguous outer column reference '{column.name}' for alias '{alias}'" - ) - continue - replacement_name = source_aliases.get(column_key) - if column_key not in source_columns_lower and replacement_name is None: - continue - if replacement_name is None and column_key in ambiguous_source_columns_lower: - if strict_source_resolution: - raise DaxTranslationError( - f"Ambiguous outer column reference '{column.name}' for alias '{alias}'" - ) - continue - else: - continue - column.set("table", exp.to_identifier("q")) - if alias != "q": - column.set("table", exp.to_identifier(alias)) - if replacement_name: - column.set("this", exp.to_identifier(replacement_name)) - return parsed.sql(dialect="duckdb") - except DaxTranslationError: - raise - except Exception as exc: - if strict_source_resolution and not allow_fallback: - raise DaxTranslationError( - f"Unable to safely correlate outer column references for alias '{alias}'" - ) from exc - if not allow_fallback: - return sql - return _fallback_rewrite_expr_for_alias(sql, alias, source_tables, source_columns) - - -_QUALIFIED_TABLE_PREFIX_RE = re.compile(r'("(?:""|[^"])*"|[A-Za-z_][A-Za-z0-9_]*)\.') - - -def _fallback_rewrite_expr_for_alias( - sql: str, - alias: str, - source_tables: set[str] | None = None, - source_columns: set[str] | None = None, -) -> str: - # Best-effort rewrite for SQLGlot parse failures. Keep expression usable against - # SELECT * FROM () AS alias by collapsing table qualifiers. - if source_tables: - rewritten = sql - source_columns_lower = {column.lower() for column in (source_columns or set())} - source_has_wildcard = "*" in source_columns_lower - for table in source_tables: - table_quoted = table.replace('"', '""') - if source_columns_lower and not source_has_wildcard: - for column in source_columns_lower: - if column == "*": - continue - rewritten = re.sub( - rf"\b{re.escape(table)}\.{re.escape(column)}\b", - f"{alias}.{column}", - rewritten, - flags=re.IGNORECASE, - ) - rewritten = rewritten.replace( - f'"{table_quoted}"."{column}"', - f"{alias}.{column}", - ) - else: - rewritten = re.sub(rf"\b{re.escape(table)}\.", f"{alias}.", rewritten, flags=re.IGNORECASE) - rewritten = rewritten.replace(f'"{table_quoted}".', f"{alias}.") - return rewritten - - rewritten = _QUALIFIED_TABLE_PREFIX_RE.sub(f"{alias}.", sql) - stripped = rewritten.strip() - if "." not in stripped and _can_qualify_identifier(stripped): - return f"{alias}.{stripped}" - return rewritten - - -def _column_table_is_local_to_select(column_expr: Any) -> bool: - try: - from sqlglot import exp - - table_name = getattr(column_expr, "table", None) - if not table_name: - return False - table_key = table_name.lower() - node = column_expr - while node is not None: - if isinstance(node, exp.Select): - if table_key in _select_scope_table_names(node): - return True - node = getattr(node, "parent", None) - return False - except Exception: - return False - - -def _column_name_is_local_to_select(column_expr: Any, known_local_columns: set[str] | None = None) -> bool: - try: - from sqlglot import exp - - column_name = getattr(column_expr, "name", None) - if not column_name: - return False - column_key = column_name.lower() - if known_local_columns and column_key in known_local_columns: - return True - if getattr(column_expr, "table", None): - return _column_table_is_local_to_select(column_expr) - - node = column_expr - while node is not None: - if isinstance(node, exp.Select): - for source_expr in _select_scope_source_exprs(node): - if _source_expr_exposes_column(source_expr, column_key): - return True - node = getattr(node, "parent", None) - return False - except Exception: - if not known_local_columns: - return False - column_name = getattr(column_expr, "name", None) - return bool(column_name and column_name.lower() in known_local_columns) - - -def _select_scope_source_exprs(select_expr: Any) -> list[Any]: - source_exprs: list[Any] = [] - from_expr = select_expr.args.get("from") - if from_expr is not None and getattr(from_expr, "this", None) is not None: - source_exprs.append(from_expr.this) - for join_expr in select_expr.args.get("joins") or []: - if getattr(join_expr, "this", None) is not None: - source_exprs.append(join_expr.this) - return source_exprs - - -def _source_expr_exposes_column(source_expr: Any, column_key: str) -> bool: - try: - from sqlglot import exp - - alias = source_expr.args.get("alias") - if isinstance(alias, exp.TableAlias): - alias_columns = [col.name.lower() for col in alias.columns if getattr(col, "name", None)] - if alias_columns: - return column_key in alias_columns - - if isinstance(source_expr, exp.Subquery): - return column_key in _query_expr_output_columns(source_expr.this) - if isinstance(source_expr, exp.Values): - if isinstance(alias, exp.TableAlias): - alias_columns = [col.name.lower() for col in alias.columns if getattr(col, "name", None)] - return column_key in alias_columns - return False - if isinstance(source_expr, exp.Paren): - return _source_expr_exposes_column(source_expr.this, column_key) - return False - except Exception: - return False - - -def _query_expr_output_columns(expr: Any) -> set[str]: - try: - from sqlglot import exp - - if isinstance(expr, exp.Subquery): - return _query_expr_output_columns(expr.this) - if isinstance(expr, exp.Paren): - return _query_expr_output_columns(expr.this) - if isinstance(expr, exp.Select): - names: set[str] = set() - for projection in expr.expressions: - if isinstance(projection, exp.Star): - names.update(name.lower() for name in _select_star_output_columns(expr)) - continue - if isinstance(projection, exp.Column) and projection.name == "*": - qualifier = projection.table if projection.table else None - names.update(name.lower() for name in _select_star_output_columns(expr, qualifier)) - continue - output_name = projection.alias_or_name - if output_name and output_name != "*": - names.add(output_name.lower()) - return names - if isinstance(expr, (exp.Union, exp.Intersect, exp.Except)): - return _query_expr_output_columns(expr.this) - return set() - except Exception: - return set() - - -def _select_star_output_columns(select_expr: Any, qualifier: str | None = None) -> set[str]: - columns: set[str] = set() - qualifier_key = qualifier.lower() if qualifier else None - for source_expr in _select_scope_source_exprs(select_expr): - if qualifier_key is not None: - source_keys: set[str] = set() - alias = getattr(source_expr, "alias_or_name", None) - if alias: - source_keys.add(alias.lower()) - table_name = getattr(source_expr, "name", None) - if table_name: - source_keys.add(table_name.lower()) - if qualifier_key not in source_keys: - continue - columns.update(_source_expr_output_columns(source_expr)) - return columns - - -def _source_expr_output_columns(source_expr: Any) -> set[str]: - try: - from sqlglot import exp - - alias = source_expr.args.get("alias") - if isinstance(alias, exp.TableAlias): - alias_columns = [col.name for col in alias.columns if getattr(col, "name", None)] - if alias_columns: - return set(alias_columns) - - if isinstance(source_expr, exp.Subquery): - return _query_expr_output_column_names(source_expr.this) - if isinstance(source_expr, exp.Values): - if isinstance(alias, exp.TableAlias): - alias_columns = [col.name for col in alias.columns if getattr(col, "name", None)] - return set(alias_columns) - return set() - if isinstance(source_expr, exp.Paren): - return _source_expr_output_columns(source_expr.this) - return set() - except Exception: - return set() - - -def _query_expr_output_column_names(expr: Any) -> set[str]: - try: - from sqlglot import exp - - if isinstance(expr, exp.Subquery): - return _query_expr_output_column_names(expr.this) - if isinstance(expr, exp.Paren): - return _query_expr_output_column_names(expr.this) - if isinstance(expr, exp.Select): - names: set[str] = set() - for projection in expr.expressions: - if isinstance(projection, exp.Star): - names.update(_select_star_output_columns(expr)) - continue - if isinstance(projection, exp.Column) and projection.name == "*": - qualifier = projection.table if projection.table else None - names.update(_select_star_output_columns(expr, qualifier)) - continue - output_name = projection.alias_or_name - if output_name and output_name != "*": - names.add(output_name) - return names - if isinstance(expr, (exp.Union, exp.Intersect, exp.Except)): - return _query_expr_output_column_names(expr.this) - return set() - except Exception: - return set() - - -def _select_scope_table_names(select_expr: Any) -> set[str]: - try: - names: set[str] = set() - source_exprs = _select_scope_source_exprs(select_expr) - - from sqlglot import exp - - for source_expr in source_exprs: - alias = getattr(source_expr, "alias_or_name", None) - if alias: - names.add(alias.lower()) - if isinstance(source_expr, exp.Table): - table_name = source_expr.name - if table_name: - names.add(table_name.lower()) - return names - except Exception: - return set() - - -def _identifier_name_from_sql(sql: str) -> str | None: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - if isinstance(parsed, exp.Identifier): - return parsed.name - if isinstance(parsed, exp.Column): - return parsed.name - except Exception: - return None - return None - - -def _column_name_from_expr_sql(sql: str) -> str | None: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - if isinstance(parsed, exp.Column): - return parsed.name - if isinstance(parsed, exp.Identifier): - return parsed.name - except Exception: - return None - return None - - -def _query_output_columns(sql: str) -> set[str]: - try: - import sqlglot - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - return _query_expr_output_column_names(parsed) - except Exception: - return set() - - -def _query_expr_output_column_name_counts(expr: Any) -> dict[str, int]: - try: - from sqlglot import exp - - if isinstance(expr, exp.Subquery): - return _query_expr_output_column_name_counts(expr.this) - if isinstance(expr, exp.Paren): - return _query_expr_output_column_name_counts(expr.this) - if isinstance(expr, exp.Select): - counts: dict[str, int] = {} - for projection in expr.expressions: - if isinstance(projection, exp.Star): - for name in _select_star_output_columns(expr): - key = name.lower() - counts[key] = counts.get(key, 0) + 1 - continue - if isinstance(projection, exp.Column) and projection.name == "*": - qualifier = projection.table if projection.table else None - for name in _select_star_output_columns(expr, qualifier): - key = name.lower() - counts[key] = counts.get(key, 0) + 1 - continue - output_name = projection.alias_or_name - if output_name and output_name != "*": - key = output_name.lower() - counts[key] = counts.get(key, 0) + 1 - return counts - if isinstance(expr, (exp.Union, exp.Intersect, exp.Except)): - return _query_expr_output_column_name_counts(expr.this) - return {} - except Exception: - return {} - - -def _query_output_column_name_counts(sql: str) -> dict[str, int]: - try: - import sqlglot - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - return _query_expr_output_column_name_counts(parsed) - except Exception: - return {} - - -def _query_source_table_names(sql: str) -> set[str]: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - table_names: set[str] = set() - for table_expr in parsed.find_all(exp.Table): - table_name = table_expr.name - if table_name: - table_names.add(table_name) - return table_names - except Exception: - return set() - - -def _query_output_width(sql: str) -> int | None: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - select_exprs = parsed.selects if hasattr(parsed, "selects") else [] - if not select_exprs: - return None - for expr in select_exprs: - if isinstance(expr, exp.Star): - return None - if isinstance(expr, exp.Column) and expr.name == "*": - return None - return len(select_exprs) - except Exception: - return None - - -def _query_uses_star_projection(sql: str) -> bool: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - select_exprs = parsed.selects if hasattr(parsed, "selects") else [] - for expr in select_exprs: - if isinstance(expr, exp.Star): - return True - if isinstance(expr, exp.Column) and expr.name == "*": - return True - return False - except Exception: - return False - - -def _query_star_projection_qualifiers(sql: str) -> set[str | None]: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - qualifiers: set[str | None] = set() - select_exprs = parsed.selects if hasattr(parsed, "selects") else [] - for expr in select_exprs: - if isinstance(expr, exp.Star): - qualifiers.add(None) - continue - if isinstance(expr, exp.Column) and expr.name == "*": - qualifiers.add(expr.table or None) - return qualifiers - except Exception: - return set() - - -def _query_output_lineage_aliases(sql: str) -> tuple[dict[str, str], set[str]]: - try: - import sqlglot - from sqlglot import exp - - parsed = sqlglot.parse_one(sql, dialect="duckdb") - select_exprs = parsed.selects if hasattr(parsed, "selects") else [] - aliases: dict[str, str] = {} - ambiguous: set[str] = set() - for expr in select_exprs: - output_name = expr.alias_or_name - if not output_name: - continue - - source_expr = expr.this if isinstance(expr, exp.Alias) else expr - if isinstance(source_expr, exp.Column): - source_name = source_expr.name - if not source_name: - continue - source_keys = [source_name.lower()] - source_table = source_expr.table - if source_table: - source_keys.append(f"{source_table.lower()}.{source_name.lower()}") - for source_key in source_keys: - if source_key in ambiguous: - continue - if source_key in aliases: - ambiguous.add(source_key) - aliases.pop(source_key, None) - continue - aliases[source_key] = output_name - return aliases, ambiguous - except Exception: - return {}, set() - - -def _is_unsupported_table_expression_error(exc: DaxTranslationError) -> bool: - message = str(exc) - return message.startswith("Unsupported table function '") or message.startswith( - "Unsupported table expression type '" - ) - - -def _order_ref_name(expr: Any, dax_ast: Any) -> str | None: - while isinstance(expr, dax_ast.Paren): - expr = expr.expr - - if isinstance(expr, dax_ast.TableColumnRef): - return expr.column - if isinstance(expr, dax_ast.HierarchyRef): - return expr.levels[-1] if expr.levels else expr.column - if isinstance(expr, dax_ast.BracketRef): - return expr.name - if isinstance(expr, dax_ast.Identifier): - return expr.name - return None - - -def _is_safe_identifier(name: str) -> bool: - if not name: - return False - first = name[0] - if not (first.isalpha() or first == "_"): - return False - for ch in name[1:]: - if not (ch.isalnum() or ch == "_"): - return False - return True - - -def _can_qualify_identifier(sql: str) -> bool: - if _is_safe_identifier(sql): - return True - if sql.startswith('"') and sql.endswith('"') and "(" not in sql and " " not in sql: - return True - return False - - -def _columns_match(left: ColumnRef, right: ColumnRef) -> bool: - if left.column.lower() != right.column.lower(): - return False - if left.table and right.table and left.table.lower() != right.table.lower(): - return False - return True - - -def _comparison_type_for_unit(unit: str) -> str: - return { - "day": "dod", - "week": "wow", - "month": "mom", - "quarter": "qoq", - "year": "yoy", - }.get(unit, "prior_period") - - -def _time_offset_for_period_function(name: str) -> tuple[int, str] | None: - return { - "previousday": (1, "day"), - "previousweek": (1, "week"), - "previousmonth": (1, "month"), - "previousquarter": (1, "quarter"), - "previousyear": (1, "year"), - "nextday": (-1, "day"), - "nextweek": (-1, "week"), - "nextmonth": (-1, "month"), - "nextquarter": (-1, "quarter"), - "nextyear": (-1, "year"), - }.get(name) - - -def _load_dax_ast(): - try: - from sidemantic_dax import ast as dax_ast - except Exception as exc: - raise DaxTranslationError("sidemantic_dax is required for DAX translation") from exc - return dax_ast diff --git a/sidemantic/loaders.py b/sidemantic/loaders.py index 47e13161..6ff69c65 100644 --- a/sidemantic/loaders.py +++ b/sidemantic/loaders.py @@ -116,7 +116,7 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: adapter = YardstickAdapter(dialect=layer.dialect or "duckdb") else: # Sidemantic SQL files (pure SQL or with YAML frontmatter) - adapter = SidemanticAdapter(lower_dax=False) + adapter = SidemanticAdapter() elif suffix == ".json": content = file_path.read_text() if '"ldm"' in content and '"datasets"' in content: @@ -150,7 +150,7 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: adapter = CubeAdapter() # Check for Sidemantic native format (explicit models: key) elif "models:" in content: - adapter = SidemanticAdapter(lower_dax=False) + adapter = SidemanticAdapter() elif "metrics:" in content and "type: " in content: adapter = MetricFlowAdapter() elif "base_sql_table:" in content and "measures:" in content: @@ -205,8 +205,6 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: # Infer cross-model relationships based on naming conventions _infer_relationships(all_models) - _lower_dax_models(all_models, all_metrics, all_parameters) - # Add all models to the layer (now with relationships) for model in all_models.values(): if model.name not in layer.graph.models: @@ -227,19 +225,6 @@ def load_from_directory(layer: "SemanticLayer", directory: str | Path) -> None: layer.graph.build_adjacency() -def _lower_dax_models(all_models: dict, all_metrics: dict, all_parameters: dict) -> None: - if not all_models and not all_metrics: - return - from sidemantic.core.semantic_graph import SemanticGraph - from sidemantic.dax.modeling import lower_dax_graph_expressions - - graph = SemanticGraph() - graph.models.update(all_models) - graph.metrics.update(all_metrics) - graph.parameters.update(all_parameters) - lower_dax_graph_expressions(graph) - - def _load_sml_directory(layer: "SemanticLayer", directory: Path, all_models: dict) -> None: """Parse an SML directory and load all models into the layer.""" from sidemantic.adapters.atscale_sml import AtScaleSMLAdapter diff --git a/sidemantic/sql/generator.py b/sidemantic/sql/generator.py index 3552ed59..6f436ec7 100644 --- a/sidemantic/sql/generator.py +++ b/sidemantic/sql/generator.py @@ -5,9 +5,7 @@ import sqlglot from sqlglot import exp, select -from sidemantic.core.metric import Metric from sidemantic.core.preagg_matcher import PreAggregationMatcher -from sidemantic.core.relationship import RelationshipOverride from sidemantic.core.semantic_graph import SemanticGraph from sidemantic.core.symmetric_aggregate import build_symmetric_aggregate_sql from sidemantic.sql.aggregation_detection import sql_has_aggregate @@ -35,64 +33,6 @@ def __init__( self.dialect = dialect self.preagg_database = preagg_database self.preagg_schema = preagg_schema - self._generate_cache: dict[tuple[object, ...], str] = {} - self._identifier_sql_cache: dict[tuple[str, str], str] = {} - - def _freeze_for_cache(self, value): - """Convert request values into stable, hashable cache keys.""" - if isinstance(value, dict): - return tuple(sorted((str(k), self._freeze_for_cache(v)) for k, v in value.items())) - if isinstance(value, list | tuple): - return tuple(self._freeze_for_cache(item) for item in value) - if isinstance(value, set): - return tuple(sorted(self._freeze_for_cache(item) for item in value)) - try: - hash(value) - except TypeError: - return repr(value) - return value - - def _generate_cache_key( - self, - metrics, - dimensions, - filters, - segments, - order_by, - limit, - offset, - parameters, - ungrouped, - use_preaggregations, - aliases, - skip_default_time_dimensions, - relationship_overrides, - ) -> tuple[object, ...]: - return ( - getattr(self.graph, "_revision", 0), - self.dialect, - self.preagg_database, - self.preagg_schema, - self._freeze_for_cache(metrics or ()), - self._freeze_for_cache(dimensions or ()), - self._freeze_for_cache(filters or ()), - self._freeze_for_cache(segments or ()), - self._freeze_for_cache(order_by or ()), - limit, - offset, - self._freeze_for_cache(parameters or {}), - ungrouped, - use_preaggregations, - self._freeze_for_cache(aliases or {}), - skip_default_time_dimensions, - self._freeze_for_cache(relationship_overrides or ()), - ) - - def _cache_generated_sql(self, cache_key: tuple[object, ...], sql: str) -> str: - if len(self._generate_cache) >= 256: - self._generate_cache.pop(next(iter(self._generate_cache))) - self._generate_cache[cache_key] = sql - return sql def _date_trunc(self, granularity: str, column_expr: str) -> str: """Generate dialect-specific time truncation expression. @@ -261,18 +201,9 @@ def _quote_identifier(self, name: str) -> str: Delegates to sqlglot which handles reserved words (e.g., 'order') and special characters automatically. """ - cache_key = (self.dialect, name) - cached = self._identifier_sql_cache.get(cache_key) - if cached is not None: - return cached - if self._is_simple_identifier(name): - sql = sqlglot.to_identifier(name, quoted=False).sql(dialect=self.dialect) - else: - sql = sqlglot.to_identifier(name, quoted=True).sql(dialect=self.dialect) - - self._identifier_sql_cache[cache_key] = sql - return sql + return sqlglot.to_identifier(name, quoted=False).sql(dialect=self.dialect) + return sqlglot.to_identifier(name, quoted=True).sql(dialect=self.dialect) def _cte_name(self, model_name: str) -> str: """Get the CTE identifier name for a model.""" @@ -282,107 +213,6 @@ def _cte_ref(self, model_name: str, column_name: str) -> str: """Build a quoted reference to a CTE column.""" return f"{self._quote_identifier(self._cte_name(model_name))}.{self._quote_identifier(column_name)}" - def _metric_for_ref(self, metric_ref: str, model_context: str | None = None) -> tuple[Metric | None, str | None]: - if "." in metric_ref: - model_name, metric_name = metric_ref.split(".", 1) - try: - model = self.graph.get_model(model_name) - except KeyError: - model = None - if model is not None: - metric = model.get_metric(metric_name) - if metric is not None: - return metric, model_name - try: - return self.graph.get_metric(metric_ref), None - except KeyError: - return None, None - - if model_context: - try: - model = self.graph.get_model(model_context) - except KeyError: - model = None - if model is not None: - metric = model.get_metric(metric_ref) - if metric is not None: - return metric, model_context - - try: - return self.graph.get_metric(metric_ref), None - except KeyError: - pass - - matches = [] - for model_name, model in self.graph.models.items(): - metric = model.get_metric(metric_ref) - if metric is not None: - matches.append((metric, model_name)) - if len(matches) == 1: - return matches[0] - return None, None - - def _collect_relationship_overrides(self, metric_refs: list[str]) -> list[RelationshipOverride]: - overrides: list[RelationshipOverride] = [] - seen_overrides: set[tuple[str, str, str, str, str | None, str | None]] = set() - visited_metrics: set[tuple[str | None, str]] = set() - - def add_override(override: RelationshipOverride) -> None: - key = ( - override.from_model, - override.from_column, - override.to_model, - override.to_column, - override.join_type, - override.direction, - ) - if key in seen_overrides: - return - seen_overrides.add(key) - overrides.append(override) - - def visit_ref(metric_ref: str, model_context: str | None = None) -> None: - metric, owner_model = self._metric_for_ref(metric_ref, model_context) - if metric is None: - return - key = (owner_model, metric.name) - if key in visited_metrics: - return - visited_metrics.add(key) - - for override in metric.relationship_overrides or []: - add_override(override) - - if metric.type == "ratio": - if metric.numerator: - visit_ref(metric.numerator, owner_model) - if metric.denominator: - visit_ref(metric.denominator, owner_model) - elif metric.type == "derived" or (not metric.type and not metric.agg and metric.sql): - for dependency in metric.get_dependencies(self.graph, owner_model): - visit_ref(dependency, owner_model) - - for metric_ref in metric_refs: - visit_ref(metric_ref) - - return overrides - - @staticmethod - def _relationship_overrides_cache_key( - relationship_overrides: list[RelationshipOverride], - ) -> tuple[tuple[str, str, str, str, str | None, str | None], ...]: - return tuple( - ( - override.from_model, - override.from_column, - override.to_model, - override.to_column, - override.join_type, - override.direction, - ) - for override in relationship_overrides - ) - def _apply_default_time_dimensions(self, metrics: list[str], dimensions: list[str]) -> list[str]: """Auto-include default_time_dimension from models if not already present. @@ -509,26 +339,6 @@ def generate( segments = segments or [] parameters = parameters or {} aliases = aliases or {} - relationship_overrides = self._collect_relationship_overrides(metrics) - - cache_key = self._generate_cache_key( - metrics, - dimensions, - filters, - segments, - order_by, - limit, - offset, - parameters, - ungrouped, - use_preaggregations, - aliases, - skip_default_time_dimensions, - self._relationship_overrides_cache_key(relationship_overrides), - ) - cached = self._generate_cache.get(cache_key) - if cached is not None: - return cached # Auto-include default_time_dimension from metrics if not already present if not skip_default_time_dimensions: @@ -635,8 +445,7 @@ def metric_needs_window(m): needs_window_functions = any(metric_needs_window(m) for m in metrics) if needs_window_functions: - sql = self._generate_with_window_functions(metrics, dimensions, filters, order_by, limit, offset, aliases) - return self._cache_generated_sql(cache_key, sql) + return self._generate_with_window_functions(metrics, dimensions, filters, order_by, limit, offset, aliases) # Parse dimension references and extract granularities parsed_dims = self._parse_dimension_refs(dimensions) @@ -646,8 +455,8 @@ def metric_needs_window(m): # Check if we need symmetric aggregation (pre-aggregation approach) # This is needed when metrics come from different models at different join levels - if self._needs_preaggregation_for_fanout(metrics, dimensions, relationship_overrides): - sql = self._generate_with_preaggregation( + if self._needs_preaggregation_for_fanout(metrics, dimensions): + return self._generate_with_preaggregation( metrics=metrics, dimensions=dimensions, filters=filters, @@ -657,7 +466,6 @@ def metric_needs_window(m): offset=offset, aliases=aliases, ) - return self._cache_generated_sql(cache_key, sql) # Try to use pre-aggregation if enabled (single model queries only) if use_preaggregations and len(model_names) == 1 and not ungrouped: @@ -675,7 +483,7 @@ def metric_needs_window(m): instrumentation = self._generate_instrumentation_comment( models=[model_names[0]], metrics=metrics, dimensions=dimensions, used_preagg=True ) - return self._cache_generated_sql(cache_key, preagg_sql + "\n" + instrumentation) + return preagg_sql + "\n" + instrumentation if not model_names: raise ValueError("No models found for query") @@ -689,7 +497,7 @@ def metric_needs_window(m): for model_b in list(model_names)[i + 1 :]: # Find join path and add intermediate models try: - join_path = self.graph.find_relationship_path(model_a, model_b, relationship_overrides) + join_path = self.graph.find_relationship_path(model_a, model_b) for jp in join_path: all_models.add(jp.from_model) all_models.add(jp.to_model) @@ -758,7 +566,6 @@ def metric_needs_window(m): order_by=order_by, all_models=all_models, metric_filter_columns=metric_filter_cols, - relationship_overrides=relationship_overrides, ) cte_sqls.append(cte_sql) @@ -775,7 +582,6 @@ def metric_needs_window(m): offset=offset, ungrouped=ungrouped, aliases=aliases, - relationship_overrides=relationship_overrides, ) # Combine CTEs and main query @@ -792,7 +598,7 @@ def metric_needs_window(m): ) full_sql = full_sql + "\n" + instrumentation - return self._cache_generated_sql(cache_key, full_sql) + return full_sql def _parse_dimension_refs(self, dimensions: list[str]) -> list[tuple[str, str | None]]: """Parse dimension references to extract granularities. @@ -911,70 +717,37 @@ def add_model(model_name: str): models.append(model_name) seen.add(model_name) - def collect_models_from_metric_object(metric: Metric, model_context: str | None, visited: set[str]): - metric_key = f"{model_context or ''}.{metric.name}" - if metric_key in visited: - return - visited.add(metric_key) - - for required_model in metric.required_models or []: - add_model(required_model) - - for override in metric.relationship_overrides or []: - add_model(override.from_model) - add_model(override.to_model) - - if metric.type == "ratio": - if metric.numerator: - collect_models_from_metric(metric.numerator, model_context, visited) - if metric.denominator: - collect_models_from_metric(metric.denominator, model_context, visited) - elif metric.type == "derived" or (not metric.type and not metric.agg and metric.sql): - for ref_metric in metric.get_dependencies(self.graph, model_context): - collect_models_from_metric(ref_metric, model_context, visited) - if metric.sql: - for model_name in self._extract_models_from_sql(metric.sql): - add_model(model_name) - elif metric.agg and metric.sql: - for model_name in self._extract_models_from_sql(metric.sql): - add_model(model_name) - - def collect_models_from_metric_with_visited(metric_ref: str, model_context: str | None, visited: set[str]): - """Recursively collect models needed from a metric with cycle protection.""" + def collect_models_from_metric(metric_ref: str): + """Recursively collect models needed from a metric.""" if "." in metric_ref: # Direct measure reference (model.measure) - model_name, measure_name = metric_ref.split(".", 1) - add_model(model_name) - try: - model = self.graph.get_model(model_name) - except KeyError: - model = None - if model: - metric = model.get_metric(measure_name) - if metric: - collect_models_from_metric_object(metric, model_name, visited) + add_model(metric_ref.split(".")[0]) else: # It's a metric, need to resolve its dependencies try: metric = self.graph.get_metric(metric_ref) if metric: - collect_models_from_metric_object(metric, model_context, visited) + if metric.type == "ratio": + if metric.numerator: + collect_models_from_metric(metric.numerator) + if metric.denominator: + collect_models_from_metric(metric.denominator) + elif metric.type == "derived" or (not metric.type and not metric.agg and metric.sql): + # Derived or untyped metrics with sql - auto-detect dependencies + for ref_metric in metric.get_dependencies(self.graph): + collect_models_from_metric(ref_metric) + # Inline SQL expression metrics (e.g., SUM(orders.amount)) + # can have empty dependencies, so also parse model refs directly. + if metric.sql: + for model_name in self._extract_models_from_sql(metric.sql): + add_model(model_name) + elif metric.agg and metric.sql: + # Graph-level simple aggregations can qualify fields + # (e.g., SUM(orders.amount)); include those models. + for model_name in self._extract_models_from_sql(metric.sql): + add_model(model_name) except KeyError: - if model_context: - try: - model = self.graph.get_model(model_context) - except KeyError: - model = None - if model: - metric = model.get_metric(metric_ref) - if metric: - collect_models_from_metric_object(metric, model_context, visited) - - def collect_models_from_metric( - metric_ref: str, model_context: str | None = None, visited: set[str] | None = None - ): - """Recursively collect models needed from a metric.""" - collect_models_from_metric_with_visited(metric_ref, model_context, visited or set()) + pass # Collect from dimensions first (since they define the grain) for dim in dimensions: @@ -1313,7 +1086,6 @@ def _build_model_cte( order_by: list[str] | None = None, all_models: set[str] | None = None, metric_filter_columns: set[str] | None = None, - relationship_overrides: list[RelationshipOverride] | None = None, ) -> str: """Build CTE SQL for a model with optional filter pushdown. @@ -1325,8 +1097,6 @@ def _build_model_cte( order_by: Order by fields (for determining needed dimensions) all_models: All models in query (for determining if joins needed) metric_filter_columns: Columns needed for metric-level filters - relationship_overrides: Query-local relationship edges that may need - additional join key columns in this CTE Returns: CTE SQL string @@ -1334,7 +1104,6 @@ def _build_model_cte( model = self.graph.get_model(model_name) all_models = all_models or {model_name} needs_joins = len(all_models) > 1 - relationship_overrides = relationship_overrides or [] # Find which dimensions are actually needed needed_dimensions = self._find_needed_dimensions( @@ -1395,17 +1164,6 @@ def _build_model_cte( select_cols.append(f"{self._quote_identifier(fk)} AS {self._quote_alias(fk)}") columns_added.add(fk) - for override in relationship_overrides: - if override.from_model == model_name: - join_key = override.from_column - elif override.to_model == model_name: - join_key = override.to_column - else: - continue - if join_key not in columns_added: - select_cols.append(f"{join_key} AS {self._quote_alias(join_key)}") - columns_added.add(join_key) - # Determine table alias for {model} placeholder replacement # In CTEs, we're selecting from the raw table (or subquery AS t) model_table_alias = "t" if model.sql else "" @@ -1653,12 +1411,7 @@ def collect_measures_from_metric(metric_ref: str, visited: set[str] | None = Non return cte_sql - def _has_fanout_joins( - self, - base_model_name: str, - other_models: list[str], - relationship_overrides: list[RelationshipOverride] | None = None, - ) -> dict[str, bool]: + def _has_fanout_joins(self, base_model_name: str, other_models: list[str]) -> dict[str, bool]: """Determine which models need symmetric aggregates due to fan-out. When one-to-many joins exist from the base model, measures from @@ -1679,11 +1432,7 @@ def _has_fanout_joins( for other_model in other_models: try: - join_path = self.graph.find_relationship_path( - base_model_name, - other_model, - relationship_overrides, - ) + join_path = self.graph.find_relationship_path(base_model_name, other_model) if not join_path: continue # Check all hops: any one_to_many in the path creates fan-out @@ -1711,12 +1460,7 @@ def _has_fanout_joins( return needs_symmetric - def _needs_preaggregation_for_fanout( - self, - metrics: list[str], - dimensions: list[str], - relationship_overrides: list[RelationshipOverride] | None = None, - ) -> bool: + def _needs_preaggregation_for_fanout(self, metrics: list[str], dimensions: list[str]) -> bool: """Determine if pre-aggregation is needed to avoid fan-out. Pre-aggregation is needed when: @@ -1757,7 +1501,7 @@ def _needs_preaggregation_for_fanout( for model_b in metric_model_list[i + 1 :]: try: # Check path from A to B - join_path = self.graph.find_relationship_path(model_a, model_b, relationship_overrides) + join_path = self.graph.find_relationship_path(model_a, model_b) if join_path: # If any hop is many_to_one (from A's perspective), model_a metrics # would be replicated when joining to model_b @@ -1768,7 +1512,7 @@ def _needs_preaggregation_for_fanout( return True # Check reverse path - join_path_reverse = self.graph.find_relationship_path(model_b, model_a, relationship_overrides) + join_path_reverse = self.graph.find_relationship_path(model_b, model_a) if join_path_reverse: for jp in join_path_reverse: if jp.relationship == "many_to_one": @@ -1789,7 +1533,6 @@ def _generate_with_preaggregation( limit: int | None = None, offset: int | None = None, aliases: dict[str, str] | None = None, - relationship_overrides: list[RelationshipOverride] | None = None, ) -> str: """Generate SQL using pre-aggregation to avoid fan-out. @@ -2016,7 +1759,6 @@ def _build_main_select( offset: int | None = None, ungrouped: bool = False, aliases: dict[str, str] | None = None, - relationship_overrides: list[RelationshipOverride] | None = None, ) -> str: """Build main SELECT using SQLGlot builder API. @@ -2032,14 +1774,13 @@ def _build_main_select( offset: Row offset ungrouped: If True, return raw rows without aggregation aliases: Custom aliases for fields (dict mapping field reference to alias) - relationship_overrides: Query-local relationship edges for pathing Returns: SQL SELECT statement """ aliases = aliases or {} # Detect if symmetric aggregates are needed - symmetric_agg_needed = self._has_fanout_joins(base_model_name, other_models, relationship_overrides) + symmetric_agg_needed = self._has_fanout_joins(base_model_name, other_models) # Check for dimension/metric name collisions across models # If there are collisions, prefix with model name @@ -2180,7 +1921,7 @@ def _build_main_select( joined_models = {base_model_name} for other_model in other_models: - join_path = self.graph.find_relationship_path(base_model_name, other_model, relationship_overrides) + join_path = self.graph.find_relationship_path(base_model_name, other_model) if join_path: # Apply each join in the path for jp in join_path: @@ -2203,7 +1944,7 @@ def _build_main_select( join_cond = " AND ".join(join_conditions) # Use INNER JOIN if this model has filters applied, otherwise LEFT JOIN - join_type = jp.join_type_override or ("inner" if jp.to_model in models_with_filters else "left") + join_type = "inner" if jp.to_model in models_with_filters else "left" query = query.join(right_table, on=join_cond, join_type=join_type) joined_models.add(jp.to_model) diff --git a/sidemantic/validation.py b/sidemantic/validation.py index 69dc94ef..e3edb6db 100644 --- a/sidemantic/validation.py +++ b/sidemantic/validation.py @@ -68,9 +68,9 @@ def validate_model(model: "Model") -> list[str]: if not model.primary_key: errors.append(f"Model '{model.name}' must have a primary_key defined") - # Check for table or SQL - if not model.table and not model.sql: - errors.append(f"Model '{model.name}' must have either 'table' or 'sql' defined") + # Check for table, SQL, or preserved DAX expression source. + if not model.table and not model.sql and not model.dax: + errors.append(f"Model '{model.name}' must have 'table', 'sql', or 'dax' defined") # Check that dimensions have valid types for dim in model.dimensions: diff --git a/tests/adapters/tmdl/test_external_tmdl_fixtures.py b/tests/adapters/tmdl/test_external_tmdl_fixtures.py index 03cef924..936e548b 100644 --- a/tests/adapters/tmdl/test_external_tmdl_fixtures.py +++ b/tests/adapters/tmdl/test_external_tmdl_fixtures.py @@ -13,15 +13,11 @@ FIXTURE_ROOT = ROOT / "fixtures" / "external_powerbi" TMDL_FIXTURES = [ - pytest.param( - "microsoft-analysis-services-sales", 11, 29, 5, 1, {"dax_translation_fallback": 2}, id="analysis-services" - ), + pytest.param("microsoft-analysis-services-sales", 11, 29, 5, 1, {}, id="analysis-services"), pytest.param("microsoft-fabric-samples-bank-customer-churn", 1, 4, 0, 0, {}, id="fabric-samples"), pytest.param("pbi-tools-adventureworks-dw2020", 7, 0, 8, 2, {}, id="adventureworks"), pytest.param("pbip-lineage-explorer-sample", 6, 7, 3, 0, {}, id="pbip-lineage"), - pytest.param( - "ruiromano-pbip-demo-agentic-model01", 4, 15, 4, 1, {"dax_translation_fallback": 2}, id="pbip-demo-agentic" - ), + pytest.param("ruiromano-pbip-demo-agentic-model01", 4, 15, 4, 1, {}, id="pbip-demo-agentic"), ] diff --git a/tests/adapters/tmdl/test_parsing.py b/tests/adapters/tmdl/test_parsing.py index d1777d5e..197a96f6 100644 --- a/tests/adapters/tmdl/test_parsing.py +++ b/tests/adapters/tmdl/test_parsing.py @@ -56,10 +56,10 @@ def test_import_tmdl_directory(): assert total_sales.format == "$#,##0.00" sales_ly = sales.get_metric("Sales LY") - assert sales_ly.type == "time_comparison" - assert sales_ly.base_metric == "Sales.Total Sales" - assert sales_ly.comparison_type == "yoy" - assert sales_ly.calculation == "previous_value" + assert sales_ly.type == "derived" + assert sales_ly.expression_language == "dax" + assert sales_ly.sql == sales_ly.dax + assert "SAMEPERIODLASTYEAR" in sales_ly.dax backtick = sales.get_metric("Backtick Measure") assert backtick.agg == "sum" @@ -245,17 +245,15 @@ def test_tmdl_realistic_fixture_import_export_contract(tmp_path): sales = graph.models["Sales"] assert sales.description == "Sales fact table" assert sales.get_dimension("Amount x2").dax == "Sales[Amount] * 2" - assert sales.get_dimension("Amount x2").sql == "(Amount * 2)" + assert sales.get_dimension("Amount x2").sql == "Sales[Amount] * 2" assert sales.get_metric("Total Sales").dax == "SUM(Sales[Amount])" assert sales.get_metric("Total Sales").sql == "Amount" assert getattr(sales, "_tmdl_child_nodes")[0].name == "TableTag" calculated = graph.models["Sales By Category"] - assert calculated.sql == ( - "SELECT Products.Category, SUM(Sales.Amount) AS Revenue FROM Products " - "LEFT JOIN Sales ON Products.ProductKey = Sales.ProductKey GROUP BY Products.Category" - ) - assert getattr(calculated, "_dax_required_models") == ["Products", "Sales"] + assert calculated.table is None + assert calculated.sql is None + assert calculated.dax == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' assert getattr(calculated, "_tmdl_child_nodes")[0].name == "CalculationTag" sales_products = next(rel for rel in sales.relationships if rel.name == "Products") @@ -269,8 +267,10 @@ def test_tmdl_realistic_fixture_import_export_contract(tmp_path): total_sales = next(metric for metric in sales_info["metrics"] if metric["name"] == "Total Sales") assert sales_info["source_format"] == "TMDL" assert products_rel["tmdl"]["child_nodes"][0]["name"] == "RelationshipLineage" - assert total_sales["faithful_lowering"] is True + assert total_sales["dax"] == "SUM(Sales[Amount])" + assert total_sales["expression_language"] == "dax" assert calculated_info["kind"] == "calculated_table" + assert calculated_info["dax"] == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' assert calculated_info["tmdl"]["child_nodes"][0]["name"] == "CalculationTag" layer = SemanticLayer() @@ -360,11 +360,8 @@ def test_tmdl_calculated_table_multitable_summarizecolumns(): model = graph.models["SalesByCategory"] assert model.table is None - assert model.sql is not None - assert "LEFT JOIN" in model.sql - assert "Products.ProductKey" in model.sql - assert "Sales.ProductKey" in model.sql - assert "GROUP BY Products.Category" in model.sql + assert model.sql is None + assert model.dax == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' description = describe_graph(graph) model_info = next(item for item in description["models"] if item["name"] == "SalesByCategory") @@ -373,8 +370,7 @@ def test_tmdl_calculated_table_multitable_summarizecolumns(): assert ( model_info["original_expression"] == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' ) - assert model_info["dax_lowered"] is True - assert model_info["dax_required_models"] == ["Products", "Sales"] + assert model_info["dax"] == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' finally: temp_path.unlink() @@ -515,7 +511,7 @@ def test_tmdl_measure_derived_expression(): temp_path.unlink() -def test_tmdl_measure_time_comparison_with_inline_aggregate_base(): +def test_tmdl_measure_preserves_complex_dax_source(): tmdl = textwrap.dedent( """ table 'Sales' @@ -539,19 +535,16 @@ def test_tmdl_measure_time_comparison_with_inline_aggregate_base(): model = graph.models["Sales"] sales_ly_inline = model.get_metric("Sales LY Inline") - assert sales_ly_inline.type == "time_comparison" - assert sales_ly_inline.base_metric == "Sales.__sales_ly_inline_base" - assert sales_ly_inline.comparison_type == "yoy" - assert sales_ly_inline.calculation == "previous_value" - - inline_base = model.get_metric("__sales_ly_inline_base") - assert inline_base.agg == "sum" - assert inline_base.sql == "Amount" + assert sales_ly_inline.type == "derived" + assert sales_ly_inline.expression_language == "dax" + assert sales_ly_inline.dax == "CALCULATE(SUM('Sales'[Amount]), SAMEPERIODLASTYEAR('Sales'[Order Date]))" + assert sales_ly_inline.sql == sales_ly_inline.dax + assert [metric.name for metric in model.metrics] == ["Sales LY Inline"] finally: temp_path.unlink() -def test_tmdl_measure_totalytd_preserves_filters(): +def test_tmdl_measure_preserves_totalytd_dax_source(): tmdl = textwrap.dedent( """ table Sales @@ -576,11 +569,10 @@ def test_tmdl_measure_totalytd_preserves_filters(): adapter = TMDLAdapter() graph = adapter.parse(temp_path) metric = graph.models["Sales"].get_metric("SalesYTDFiltered") - assert metric.type == "cumulative" - assert metric.grain_to_date == "year" - assert metric.agg == "sum" - assert metric.sql == "Amount" - assert metric.filters == ["(ProductKey = 1)"] + assert metric.type == "derived" + assert metric.expression_language == "dax" + assert metric.dax == "TOTALYTD(CALCULATE(SUM(Sales[Amount]), Sales[ProductKey] = 1), Sales[OrderDate])" + assert metric.sql == metric.dax finally: temp_path.unlink() @@ -627,50 +619,6 @@ def test_tmdl_import_many_to_many_relationship_preserves_join_keys(): temp_path.unlink() -def test_tmdl_import_collects_dax_translation_fallback_warnings(monkeypatch): - tmdl = textwrap.dedent( - """ - table Sales - column Amount - dataType: decimal - sourceColumn: Amount - calculatedColumn BadColumn = BADFUNC(Sales[Amount]) - measure BadMeasure = BADFUNC(Sales[Amount]) - calculatedTable BadTable = BADTABLE(Sales) - """ - ) - - monkeypatch.setattr(tmdl_module, "_parse_dax_expression", lambda expression, node, context: object()) - monkeypatch.setattr( - tmdl_module, - "translate_dax_scalar", - lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("scalar unsupported")), - ) - monkeypatch.setattr( - tmdl_module, - "translate_dax_metric", - lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("metric unsupported")), - ) - monkeypatch.setattr( - tmdl_module, - "translate_dax_table", - lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("table unsupported")), - ) - - with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: - f.write(tmdl) - temp_path = Path(f.name) - - try: - graph = TMDLAdapter().parse(temp_path) - warnings = getattr(graph, "import_warnings") - assert len(warnings) == 3 - assert {warning["context"] for warning in warnings} == {"column", "measure", "calculated_table"} - assert {warning["code"] for warning in warnings} == {"dax_translation_fallback"} - finally: - temp_path.unlink() - - def test_tmdl_import_collects_dax_parse_warnings(monkeypatch): tmdl = textwrap.dedent( """ @@ -757,7 +705,7 @@ def test_tmdl_import_collects_relationship_skip_warnings(): temp_path.unlink() -def test_tmdl_calculated_table_lowering_ignores_inactive_relationship_edges(tmp_path): +def test_tmdl_inactive_relationship_is_preserved_and_excluded_from_graph_paths(tmp_path): pytest.importorskip("sidemantic_dax") _write_tmdl_dax_relationship_fixture( tmp_path, @@ -774,14 +722,17 @@ def test_tmdl_calculated_table_lowering_ignores_inactive_relationship_edges(tmp_ graph = TMDLAdapter().parse(tmp_path) warnings = getattr(graph, "import_warnings") - assert [warning["code"] for warning in warnings] == ["dax_unrelated_cross_join"] - assert warnings[0]["context"] == "calculated_table" + assert warnings == [] assert [(rel.name, rel.active) for rel in graph.models["Sales"].relationships] == [("Products", False)] - assert "CROSS JOIN" in graph.models["Sales By Category"].sql - assert "LEFT JOIN" not in graph.models["Sales By Category"].sql + assert graph.models["Sales By Category"].sql is None + assert ( + graph.models["Sales By Category"].dax == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' + ) + with pytest.raises(ValueError, match="No join path found"): + graph.find_relationship_path("Sales", "Products") -def test_tmdl_calculated_table_lowering_ignores_invalid_relationship_edges(tmp_path): +def test_tmdl_invalid_relationship_edges_are_skipped(tmp_path): pytest.importorskip("sidemantic_dax") _write_tmdl_dax_relationship_fixture( tmp_path, @@ -797,13 +748,14 @@ def test_tmdl_calculated_table_lowering_ignores_invalid_relationship_edges(tmp_p graph = TMDLAdapter().parse(tmp_path) warnings = getattr(graph, "import_warnings") - assert [warning["code"] for warning in warnings] == ["dax_unrelated_cross_join", "relationship_parse_skip"] - assert warnings[0]["context"] == "calculated_table" - assert warnings[1]["context"] == "relationship" - assert "unsupported cardinality" in warnings[1]["message"] + assert [warning["code"] for warning in warnings] == ["relationship_parse_skip"] + assert warnings[0]["context"] == "relationship" + assert "unsupported cardinality" in warnings[0]["message"] assert graph.models["Sales"].relationships == [] - assert "CROSS JOIN" in graph.models["Sales By Category"].sql - assert "LEFT JOIN" not in graph.models["Sales By Category"].sql + assert graph.models["Sales By Category"].sql is None + assert ( + graph.models["Sales By Category"].dax == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' + ) def _write_tmdl_dax_relationship_fixture(root: Path, relationship_text: str) -> None: @@ -978,14 +930,12 @@ def test_tmdl_import_valid_relationship_cardinalities_do_not_emit_skip_warnings( temp_path.unlink() -def test_tmdl_warning_fixture_collects_real_unsupported_dax_warnings(): +def test_tmdl_warning_fixture_collects_relationship_warnings(): + pytest.importorskip("sidemantic_dax") graph = TMDLAdapter().parse("tests/fixtures/tmdl_warning") warnings = getattr(graph, "import_warnings") assert [(warning["code"], warning["context"], warning["name"]) for warning in warnings] == [ - ("dax_translation_fallback", "calculated_table", "Bad Table"), - ("dax_translation_fallback", "column", "Bad Column"), - ("dax_translation_fallback", "measure", "Bad Measure"), ("relationship_parse_skip", "relationship", "Bad-Relationship"), ] assert all(warning.get("file") for warning in warnings) @@ -1040,8 +990,7 @@ def test_tmdl_loader_auto_detects_standalone_tmdl_files(tmp_path): assert revenue["source_format"] == "TMDL" assert revenue["source_file"] == "Sales.tmdl" assert revenue["dax"] == "SUM(Sales[Amount])" - assert revenue["dax_lowered"] is True - assert revenue["faithful_lowering"] is True + assert revenue["expression_language"] == "dax" def test_tmdl_loader_preserves_graph_passthrough_for_export(tmp_path): @@ -1230,20 +1179,19 @@ def test_tmdl_import_warnings_are_model_qualified_for_duplicate_names(monkeypatc column Amount dataType: decimal sourceColumn: Amount - measure Revenue = UNSUPPORTED(Sales[Amount]) + measure Revenue = BROKEN(Sales[Amount]) table Returns column Amount dataType: decimal sourceColumn: Amount - measure Revenue = UNSUPPORTED(Returns[Amount]) + measure Revenue = BROKEN(Returns[Amount]) """ ) - monkeypatch.setattr(tmdl_module, "_parse_dax_expression", lambda expression, node, context: object()) monkeypatch.setattr( tmdl_module, - "translate_dax_metric", - lambda *args, **kwargs: (_ for _ in ()).throw(tmdl_module.DaxTranslationError("metric unsupported")), + "_parse_dax_expression", + lambda expression, node, context: (_ for _ in ()).throw(ValueError("metric parse unsupported")), ) with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: diff --git a/tests/dax/test_model_authoring.py b/tests/dax/test_model_authoring.py deleted file mode 100644 index d94e20f2..00000000 --- a/tests/dax/test_model_authoring.py +++ /dev/null @@ -1,748 +0,0 @@ -from __future__ import annotations - -import json -from types import SimpleNamespace - -import pytest -import yaml - -from sidemantic import SemanticLayer -from sidemantic.adapters.sidemantic import SidemanticAdapter -from sidemantic.core.introspection import describe_graph -from sidemantic.dax.modeling import DaxModelingError - -pytest.importorskip("sidemantic_dax") - - -def _write_native_dax_model(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: category - type: categorical - - name: doubled_amount - type: numeric - dax: "'sales'[amount] * 2" - metrics: - - name: revenue - dax: "SUM('sales'[amount])" -""" - ) - return path - - -def test_native_sidemantic_dax_authoring_lowers_and_preserves_source(tmp_path): - layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) - sales = layer.get_model("sales") - - doubled = sales.get_dimension("doubled_amount") - assert doubled.sql == "(amount * 2)" - assert doubled.dax == "'sales'[amount] * 2" - assert doubled.expression_language == "dax" - - revenue = sales.get_metric("revenue") - assert revenue.agg == "sum" - assert revenue.sql == "amount" - assert revenue.dax == "SUM('sales'[amount])" - assert revenue.expression_language == "dax" - - sidemantic_sql = layer.compile(metrics=["sales.revenue"], dimensions=["sales.category"]) - assert "SUM(sales_cte.revenue_raw)" in sidemantic_sql - - -def test_native_sidemantic_expression_language_dax_uses_sql_text_as_dax_source(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - metrics: - - name: revenue - expression_language: dax - sql: "SUM('sales'[amount])" -""" - ) - - layer = SemanticLayer.from_yaml(path) - revenue = layer.get_model("sales").get_metric("revenue") - - assert revenue.agg == "sum" - assert revenue.sql == "amount" - assert revenue.dax == "SUM('sales'[amount])" - assert revenue.expression_language == "dax" - - -def test_native_sidemantic_public_false_round_trips_for_model_items(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: internal_category - type: categorical - sql: category - public: false - metrics: - - name: internal_revenue - dax: "SUM('sales'[amount])" - public: false -metrics: - - name: global_internal - type: derived - sql: "1" - public: false -""" - ) - - graph = SidemanticAdapter().parse(path) - sales = graph.models["sales"] - assert sales.get_dimension("internal_category").public is False - assert sales.get_metric("internal_revenue").public is False - assert graph.metrics["global_internal"].public is False - - output = tmp_path / "exported.yml" - SidemanticAdapter().export(graph, output) - exported = yaml.safe_load(output.read_text()) - exported_dimension = exported["models"][0]["dimensions"][0] - exported_metric = exported["models"][0]["metrics"][0] - exported_graph_metric = exported["metrics"][0] - assert exported_dimension["public"] is False - assert exported_metric["public"] is False - assert exported_graph_metric["public"] is False - - -def test_native_sidemantic_graph_metric_expression_language_dax_lowers_and_exports(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: amount - type: numeric -metrics: - - name: revenue - expression_language: dax - sql: "SUM('sales'[amount])" -""" - ) - - graph = SidemanticAdapter().parse(path) - revenue = graph.metrics["revenue"] - - assert revenue.agg == "sum" - assert revenue.sql == "amount" - assert revenue.dax == "SUM('sales'[amount])" - assert revenue.expression_language == "dax" - assert getattr(revenue, "_dax_lowered") is True - - output = tmp_path / "exported.yml" - SidemanticAdapter().export(graph, output) - exported = yaml.safe_load(output.read_text()) - exported_metric = exported["metrics"][0] - assert exported_metric["dax"] == "SUM('sales'[amount])" - assert exported_metric["expression_language"] == "dax" - assert "sql" not in exported_metric - - -def test_native_sidemantic_graph_metric_expression_language_dax_requires_single_model_context(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - - name: returns - table: returns - primary_key: id -metrics: - - name: revenue - expression_language: dax - sql: "SUM('sales'[amount])" -""" - ) - - with pytest.raises(DaxModelingError, match="DAX graph metric 'revenue' needs a model context"): - SidemanticAdapter().parse(path) - - -def test_native_sidemantic_expression_language_dax_for_dimensions_and_models(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: amount_doubled - type: numeric - expression_language: dax - sql: "'sales'[amount] * 2" - - name: positive_sales - primary_key: id - expression_language: dax - sql: "FILTER('sales', 'sales'[amount] > 0)" - dimensions: - - name: id - type: numeric -""" - ) - - graph = SidemanticAdapter().parse(path) - amount_doubled = graph.models["sales"].get_dimension("amount_doubled") - positive_sales = graph.models["positive_sales"] - - assert amount_doubled.sql == "(amount * 2)" - assert amount_doubled.dax == "'sales'[amount] * 2" - assert amount_doubled.expression_language == "dax" - assert positive_sales.sql == "SELECT * FROM sales WHERE (amount > 0)" - assert positive_sales.dax == "FILTER('sales', 'sales'[amount] > 0)" - assert positive_sales.expression_language == "dax" - - -def test_native_sidemantic_model_level_dax_calculated_table_lowers(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: id - type: numeric - - name: amount - type: numeric - - name: positive_sales - primary_key: id - dax: "FILTER('sales', 'sales'[amount] > 0)" - dimensions: - - name: id - type: numeric -""" - ) - - graph = SidemanticAdapter().parse(path) - positive_sales = graph.models["positive_sales"] - assert positive_sales.sql == "SELECT * FROM sales WHERE (amount > 0)" - assert positive_sales.table is None - assert positive_sales.dax == "FILTER('sales', 'sales'[amount] > 0)" - assert positive_sales.expression_language == "dax" - assert getattr(positive_sales, "_dax_required_models") == ["sales"] - - description = describe_graph(graph) - positive_sales_info = next(model for model in description["models"] if model["name"] == "positive_sales") - assert positive_sales_info["kind"] == "calculated_table" - assert positive_sales_info["calculated_table"] is True - assert positive_sales_info["dax"] == "FILTER('sales', 'sales'[amount] > 0)" - assert positive_sales_info["original_expression"] == "FILTER('sales', 'sales'[amount] > 0)" - assert positive_sales_info["dax_lowered"] is True - assert positive_sales_info["dax_required_models"] == ["sales"] - - -def test_native_sidemantic_model_level_dax_overrides_table_source(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: id - type: numeric - - name: amount - type: numeric - - name: positive_sales - table: should_not_remain - primary_key: id - expression_language: dax - sql: "FILTER('sales', 'sales'[amount] > 0)" - dimensions: - - name: id - type: numeric -""" - ) - - graph = SidemanticAdapter().parse(path) - positive_sales = graph.models["positive_sales"] - - assert positive_sales.table is None - assert positive_sales.sql == "SELECT * FROM sales WHERE (amount > 0)" - assert positive_sales.dax == "FILTER('sales', 'sales'[amount] > 0)" - assert describe_graph(graph)["models"][1]["kind"] == "calculated_table" - - -def test_native_sidemantic_model_level_dax_surfaces_cross_join_warnings(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: id - type: numeric - - name: products - table: products - primary_key: id - dimensions: - - name: category - type: categorical - - name: sales_products - primary_key: id - dax: "SUMMARIZECOLUMNS(sales[id], products[category])" - dimensions: - - name: id - type: numeric -""" - ) - - graph = SidemanticAdapter().parse(path) - warnings = getattr(graph, "import_warnings") - - assert graph.models["sales_products"].sql == ( - "SELECT sales.id, products.category FROM sales CROSS JOIN products GROUP BY sales.id, products.category" - ) - assert warnings == [ - { - "code": "dax_unrelated_cross_join", - "context": "calculated_table", - "model": "sales_products", - "name": "sales_products", - "message": ( - "DAX query cross joins unrelated table 'products' with 'sales' because no relationship path is defined" - ), - } - ] - - -def test_native_sidemantic_export_preserves_dax_sources(tmp_path): - layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) - output = tmp_path / "exported.yml" - - SidemanticAdapter().export(layer.graph, output) - - exported = yaml.safe_load(output.read_text()) - sales = exported["models"][0] - exported_dimension = next(dim for dim in sales["dimensions"] if dim["name"] == "doubled_amount") - exported_metric = next(metric for metric in sales["metrics"] if metric["name"] == "revenue") - - assert exported_dimension["dax"] == "'sales'[amount] * 2" - assert exported_dimension["expression_language"] == "dax" - assert "sql" not in exported_dimension - assert exported_metric["dax"] == "SUM('sales'[amount])" - assert exported_metric["expression_language"] == "dax" - assert "sql" not in exported_metric - - -def test_native_sidemantic_graph_metric_dax_lowers_exports_and_describes(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: category - type: categorical -metrics: - - name: revenue - dax: "SUM('sales'[amount])" -""" - ) - layer = SemanticLayer.from_yaml(path) - - revenue = layer.graph.metrics["revenue"] - assert revenue.agg == "sum" - assert revenue.sql == "amount" - assert revenue.dax == "SUM('sales'[amount])" - assert revenue.expression_language == "dax" - - compiled = layer.compile(metrics=["revenue"], dimensions=["sales.category"]) - assert "SUM(amount) AS revenue" in compiled - - description = layer.describe_models() - graph_metric = description["metrics"][0] - assert graph_metric["name"] == "revenue" - assert graph_metric["source_format"] == "Sidemantic" - assert graph_metric["source_file"] == "models.yml" - assert graph_metric["dax"] == "SUM('sales'[amount])" - assert graph_metric["original_expression"] == "SUM('sales'[amount])" - assert graph_metric["dax_lowered"] is True - assert graph_metric["faithful_lowering"] is True - - output = tmp_path / "exported.yml" - SidemanticAdapter().export(layer.graph, output) - exported = yaml.safe_load(output.read_text()) - assert exported["metrics"] == [ - { - "name": "revenue", - "dax": "SUM('sales'[amount])", - "expression_language": "dax", - "agg": "sum", - } - ] - - -def test_native_sidemantic_dax_authoring_rejects_invalid_dax(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - metrics: - - name: revenue - dax: "SUM(" -""" - ) - - with pytest.raises(DaxModelingError, match="Could not parse DAX metric 'sales.revenue'"): - SidemanticAdapter().parse(path) - - -@pytest.mark.parametrize( - "yaml_text", - [ - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: doubled_amount - type: numeric - expression_language: sql - dax: "'sales'[amount] * 2" -""", - """ -models: - - name: sales - table: sales - primary_key: id - metrics: - - name: revenue - expression_language: sql - dax: "SUM('sales'[amount])" -""", - """ -models: - - name: positive_sales - primary_key: id - expression_language: sql - dax: "FILTER('sales', 'sales'[amount] > 0)" -""", - """ -models: - - name: sales - table: sales - primary_key: id -metrics: - - name: revenue - expression_language: sql - dax: "SUM('sales'[amount])" -""", - ], -) -def test_native_sidemantic_dax_authoring_rejects_dax_source_with_sql_language(tmp_path, yaml_text): - path = tmp_path / "models.yml" - path.write_text(yaml_text) - - with pytest.raises(DaxModelingError, match="defines dax but expression_language='sql'"): - SidemanticAdapter().parse(path) - - -def test_native_sidemantic_dax_authoring_requires_dax_extra(monkeypatch, tmp_path): - import builtins - - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - metrics: - - name: revenue - dax: "SUM('sales'[amount])" -""" - ) - - real_import = builtins.__import__ - - def _block_sidemantic_dax(name, globals=None, locals=None, fromlist=(), level=0): - if name == "sidemantic_dax" or name.startswith("sidemantic_dax."): - raise ImportError("simulated missing sidemantic_dax") - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", _block_sidemantic_dax) - - with pytest.raises(DaxModelingError, match="sidemantic_dax is required for DAX model definitions"): - SidemanticAdapter().parse(path) - - -@pytest.mark.parametrize( - ("yaml_text", "message"), - [ - ( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: bad_dimension - type: numeric - dax: "SUM(" -""", - "Could not parse DAX dimension 'sales.bad_dimension'", - ), - ( - """ -models: - - name: bad_table - primary_key: id - dax: "FILTER(" -""", - "Could not parse DAX model 'bad_table'", - ), - ], -) -def test_native_sidemantic_dax_authoring_rejects_invalid_dax_at_load_boundaries(tmp_path, yaml_text, message): - path = tmp_path / "models.yml" - path.write_text(yaml_text) - - with pytest.raises(DaxModelingError, match=message): - SidemanticAdapter().parse(path) - - -@pytest.mark.parametrize( - ("yaml_text", "message"), - [ - ( - """ -models: - - name: sales - table: sales - primary_key: id - metrics: - - name: bad_metric - dax: "UNKNOWNFUNC('sales'[amount])" -""", - "DAX metric 'sales.bad_metric' is unsupported", - ), - ( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: bad_dimension - type: numeric - dax: "UNKNOWNFUNC('sales'[amount])" -""", - "DAX dimension 'sales.bad_dimension' is unsupported", - ), - ( - """ -models: - - name: sales - table: sales - primary_key: id - - name: bad_table - primary_key: id - dax: "UNKNOWNTABLEFN('sales')" -""", - "DAX model 'bad_table' is unsupported", - ), - ( - """ -models: - - name: sales - table: sales - primary_key: id - - name: returns - table: returns - primary_key: id -metrics: - - name: bad_graph_metric - dax: "SUM('sales'[amount])" -""", - "DAX graph metric 'bad_graph_metric' needs a model context", - ), - ], -) -def test_native_sidemantic_dax_authoring_rejects_valid_unsupported_dax_at_load_boundaries(tmp_path, yaml_text, message): - path = tmp_path / "models.yml" - path.write_text(yaml_text) - - with pytest.raises(DaxModelingError, match=message): - SidemanticAdapter().parse(path) - - -def test_semantic_layer_compile_and_query_dax_use_model_metrics(tmp_path): - layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) - layer.adapter.execute("CREATE TABLE sales (id INTEGER, category VARCHAR, amount DOUBLE)") - layer.adapter.execute("INSERT INTO sales VALUES (1, 'A', 10), (2, 'A', 5), (3, 'B', 7)") - - dax = """ - EVALUATE - SUMMARIZECOLUMNS( - 'sales'[category], - "Revenue", [revenue] - ) - ORDER BY 'sales'[category] ASC - """ - sql = layer.compile_dax_query(dax) - assert "SUM(sales.amount) AS Revenue" in sql - assert "revenue AS Revenue" not in sql - - rows = layer.query_dax(dax).fetchall() - assert rows == [("A", 15.0), ("B", 7.0)] - - dry_run = layer.run_dax_query(dax, dry_run=True) - assert dry_run == { - "sql": sql, - "rows": [], - "row_count": 0, - "warnings": [], - "import_warnings": [], - } - - payload = layer.run_dax_query(dax) - assert payload["sql"] == sql - assert payload["rows"] == [{"category": "A", "Revenue": 15.0}, {"category": "B", "Revenue": 7.0}] - assert payload["row_count"] == 2 - assert payload["warnings"] == [] - assert payload["import_warnings"] == [] - json.dumps(payload) - - -def test_semantic_layer_dax_query_payload_preserves_translation_warnings(monkeypatch): - layer = SemanticLayer() - graph_warning = {"code": "query_warning", "message": "graph-level warning"} - evaluate_warning = {"code": "evaluate_warning", "message": "evaluate-level warning"} - - monkeypatch.setattr( - layer, - "translate_dax_query", - lambda _dax: SimpleNamespace( - evaluates=[SimpleNamespace(sql="SELECT 1 AS one", warnings=[evaluate_warning])], - warnings=[graph_warning], - ), - ) - - payload = layer.compile_dax_query_payload('EVALUATE ROW("one", 1)') - assert payload == { - "sql": "SELECT 1 AS one", - "warnings": [graph_warning, evaluate_warning], - "import_warnings": [], - } - assert layer.run_dax_query('EVALUATE ROW("one", 1)', dry_run=True)["warnings"] == [ - graph_warning, - evaluate_warning, - ] - - -def test_semantic_layer_dax_query_payload_warns_on_unrelated_cross_join(tmp_path): - path = tmp_path / "models.yml" - path.write_text( - """ -models: - - name: sales - table: sales - primary_key: id - dimensions: - - name: product_key - type: categorical - - name: products - table: products - primary_key: product_key - dimensions: - - name: category - type: categorical -""" - ) - layer = SemanticLayer.from_yaml(path) - - payload = layer.compile_dax_query_payload("EVALUATE SUMMARIZECOLUMNS('sales'[product_key], 'products'[category])") - - assert payload["sql"] == ( - "SELECT sales.product_key, products.category FROM sales CROSS JOIN products " - "GROUP BY sales.product_key, products.category" - ) - assert payload["warnings"] == [ - { - "code": "dax_unrelated_cross_join", - "context": "query", - "base_table": "sales", - "table": "products", - "message": ( - "DAX query cross joins unrelated table 'products' with 'sales' because no relationship path is defined" - ), - } - ] - assert payload["import_warnings"] == [] - - -def test_semantic_layer_describe_models_exposes_dax_metadata(tmp_path): - layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) - - description = layer.describe_models() - sales = description["models"][0] - revenue = next(metric for metric in sales["metrics"] if metric["name"] == "revenue") - doubled = next(dimension for dimension in sales["dimensions"] if dimension["name"] == "doubled_amount") - - assert description["import_warnings"] == [] - assert sales["kind"] == "table" - assert "calculated_table" not in sales - assert sales["source_format"] == "Sidemantic" - assert sales["source_file"] == "models.yml" - assert revenue["dax"] == "SUM('sales'[amount])" - assert revenue["source_format"] == "Sidemantic" - assert revenue["source_file"] == "models.yml" - assert revenue["original_expression"] == "SUM('sales'[amount])" - assert revenue["dax_lowered"] is True - assert revenue["faithful_lowering"] is True - assert revenue["public"] is True - assert doubled["dax"] == "'sales'[amount] * 2" - assert doubled["source_format"] == "Sidemantic" - assert doubled["source_file"] == "models.yml" - assert doubled["faithful_lowering"] is True - - -def test_semantic_layer_describe_models_marks_import_warning_status(tmp_path): - layer = SemanticLayer.from_yaml(_write_native_dax_model(tmp_path)) - layer.graph.import_warnings = [ - { - "code": "dax_translation_fallback", - "context": "measure", - "name": "revenue", - "message": "simulated warning", - } - ] - - revenue = next(metric for metric in layer.describe_models()["models"][0]["metrics"] if metric["name"] == "revenue") - - assert revenue["unsupported"] is True - assert revenue["faithful_lowering"] is False - assert revenue["import_warnings"][0]["code"] == "dax_translation_fallback" diff --git a/tests/dax/test_query_translation.py b/tests/dax/test_query_translation.py deleted file mode 100644 index f90419b7..00000000 --- a/tests/dax/test_query_translation.py +++ /dev/null @@ -1,7081 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -import sidemantic_dax - -from sidemantic.dax import DaxTranslationError, RelationshipEdge, translate_dax_query -from sidemantic.dax.translator import _rewrite_expr_for_alias - -ROOT = Path(__file__).resolve().parents[2] - - -def _parse_query(query: str): - try: - return sidemantic_dax.parse_query(query) - except RuntimeError as exc: - if "native module is not available" in str(exc): - pytest.skip("sidemantic_dax native module not available") - raise - - -def _query_docs_blocks() -> list[str]: - fixture = ROOT / "tests" / "dax" / "fixtures" / "query-docs" / "queries.txt" - text = fixture.read_text() - blocks = [block.strip() for block in text.split("---") if block.strip()] - queries: list[str] = [] - for block in blocks: - lines = [line for line in block.splitlines() if not line.strip().startswith("# source:")] - query = "\n".join(lines).strip() - if query: - queries.append(query) - return queries - - -def test_translate_query_order_by_and_start_at(): - query = _parse_query( - """ - EVALUATE - 'Sales Order' - ORDER BY 'Sales Order'[Sales Order] ASC - START AT "SO43661" - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales Order": { - "Sales Order": '"Sales Order"', - } - }, - measure_names_by_table={"Sales Order": set()}, - ) - - assert len(translation.evaluates) == 1 - sql = translation.evaluates[0].sql - assert 'SELECT * FROM (SELECT * FROM "Sales Order") AS q' in sql - assert "WHERE (q.\"Sales Order\" >= 'SO43661')" in sql - assert 'ORDER BY q."Sales Order" ASC' in sql - - -def test_translate_query_metric_reference_preserves_metric_filters(): - query = _parse_query('EVALUATE ROW("West", [West Sales])') - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": { - "Amount": "Amount", - "Region": "Region", - } - }, - measure_names_by_table={"Sales": {"West Sales"}}, - measure_aggs_by_table={"Sales": {"West Sales": "sum"}}, - measure_sql_by_table={"Sales": {"West Sales": "Amount"}}, - measure_filters_by_table={"Sales": {"West Sales": ["Sales.Region = 'West'"]}}, - ) - - sql = translation.evaluates[0].sql - assert "SUM(CASE WHEN Sales.Region = 'West' THEN Sales.Amount ELSE NULL END)" in sql - - -def test_translate_query_docs_fixture_blocks(): - column_sql_by_table = { - "Sales Order": { - "Sales Order": '"Sales Order"', - "Sales Order Line": '"Sales Order Line"', - "SalesOrderLineKey": "SalesOrderLineKey", - "CustomerKey": "CustomerKey", - "DateKey": "DateKey", - }, - "Date": { - "Month Name": '"Month Name"', - "Month of Year": '"Month of Year"', - "Fiscal Year": '"Fiscal Year"', - "DateKey": "DateKey", - }, - "Product": { - "Category": "Category", - }, - "Sales": { - "Amount": "Amount", - "ProductKey": "ProductKey", - "DateKey": "DateKey", - "CustomerKey": "CustomerKey", - }, - "Customer": { - "CustomerKey": "CustomerKey", - }, - "Unbought products": { - "Year Range": '"Year Range"', - }, - "Pick a sales measure": {}, - } - - for idx, query_text in enumerate(_query_docs_blocks(), start=1): - query = _parse_query(query_text) - translation = translate_dax_query(query, column_sql_by_table=column_sql_by_table) - assert translation.evaluates, f"Expected EVALUATE statements for query-doc block {idx}" - - -def test_translate_query_keepfilters_wrapped_table_expression(): - query = _parse_query( - """ - EVALUATE - KEEPFILTERS( - FILTER('Sales', 'Sales'[Amount] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Amount > 0)" in sql - - -def test_translate_query_nonvisual_wrapped_table_expression(): - query = _parse_query( - """ - EVALUATE - NONVISUAL( - FILTER('Sales', 'Sales'[Amount] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Amount > 0)" in sql - - -def test_translate_query_order_by_multikey_start_at_mixed_direction(): - query = _parse_query( - """ - EVALUATE - 'Sales' - ORDER BY 'Sales'[Region] ASC, 'Sales'[Amount] DESC - START AT "US", 100 - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": { - "Region": "Region", - "Amount": "Amount", - } - }, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (q.Region > 'US') OR (q.Region = 'US' AND q.Amount <= 100)" in sql - assert "ORDER BY q.Region ASC, q.Amount DESC" in sql - - -def test_translate_query_order_by_multikey_start_at_prefix(): - query = _parse_query( - """ - EVALUATE - 'Sales' - ORDER BY 'Sales'[Region] ASC, 'Sales'[Amount] DESC - START AT "US" - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": { - "Region": "Region", - "Amount": "Amount", - } - }, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (q.Region >= 'US')" in sql - assert "ORDER BY q.Region ASC, q.Amount DESC" in sql - - -def test_translate_query_order_by_expression_start_at(): - query = _parse_query( - """ - EVALUATE - 'Sales' - ORDER BY UPPER('Sales'[Region]) ASC - START AT "US" - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": { - "Region": "Region", - } - }, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (UPPER(q.Region) >= 'US')" in sql - assert "ORDER BY UPPER(q.Region) ASC" in sql - - -def test_translate_query_order_by_expression_start_at_when_sqlglot_rewrite_fails(monkeypatch): - import sqlglot - - query = _parse_query( - """ - EVALUATE - 'Sales' - ORDER BY UPPER('Sales'[Region]) ASC - START AT "US" - """ - ) - - monkeypatch.setattr(sqlglot, "parse_one", lambda *args, **kwargs: (_ for _ in ()).throw(ValueError("boom"))) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": { - "Region": "Region", - } - }, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (UPPER(q.Region) >= 'US')" in sql - assert "ORDER BY UPPER(q.Region) ASC" in sql - - -def test_translate_query_start_at_accepts_expression_value(): - query = _parse_query( - """ - EVALUATE - 'Sales' - ORDER BY 'Sales'[Order Date] ASC - START AT DATE(2024, 1, 1) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": { - "Order Date": '"Order Date"', - } - }, - ) - - sql = translation.evaluates[0].sql - assert 'WHERE (q."Order Date" >= MAKE_DATE(2024, 1, 1))' in sql - assert 'ORDER BY q."Order Date" ASC' in sql - - -def test_translate_query_summarizecolumns_order_by(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Month of Year], - "Revenue", SUM('Sales'[Amount]) - ) - ORDER BY 'Date'[Month of Year] ASC - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey", "Month of Year": '"Month of Year"'}, - "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="DateKey", - to_table="Date", - to_column="DateKey", - ) - ], - ) - - assert len(translation.evaluates) == 1 - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert 'GROUP BY "Month of Year"' in sql - assert 'ORDER BY q."Month of Year" ASC' in sql - - -def test_translate_query_summarizecolumns_countrows_cross_table_uses_relationship_group_context(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Month of Year], - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey", "Month of Year": '"Month of Year"'}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="DateKey", - to_table="Date", - to_column="DateKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(Sales.DateKey) AS Rows" in sql - assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql - assert 'GROUP BY "Month of Year"' in sql - - -def test_translate_query_summarizecolumns_countrows_relatedtable_uses_relationship_group_context(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - "Rows", COUNTROWS(RELATEDTABLE('Sales')) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="DateKey", - to_table="Date", - to_column="DateKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(Sales.DateKey) AS Rows" in sql - assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_summarizecolumns_countrows_calculatetable_related_table_uses_relationship_group_context(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - "Customers", COUNTROWS(CALCULATETABLE('Customer')) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - "Sales": {"DateKey": "DateKey", "CustomerKey": "CustomerKey"}, - "Customer": {"CustomerKey": "CustomerKey"}, - }, - relationship_edges=[ - RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), - RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), - ], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(Customer.CustomerKey) AS Customers" in sql - assert ( - "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" - in sql - ) - assert "GROUP BY Date.FiscalYear" in sql - - -@pytest.mark.parametrize("wrapper", ["VALUES", "FILTERS", "DISTINCT"]) -def test_translate_query_summarizecolumns_countrows_distinct_table_wrappers_use_group_context(wrapper: str): - query = _parse_query( - f""" - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - "Customers", COUNTROWS({wrapper}('Customer')) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - "Sales": {"DateKey": "DateKey", "CustomerKey": "CustomerKey"}, - "Customer": {"Name": "Name", "CustomerKey": "CustomerKey"}, - }, - relationship_edges=[ - RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), - RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), - ], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(DISTINCT Customer.CustomerKey) AS Customers" in sql - assert ( - "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" - in sql - ) - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_summarizecolumns_countrows_calculatetable_values_with_filter_uses_grouped_distinct_count(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - "Customers", COUNTROWS(CALCULATETABLE(VALUES('Customer'), 'Customer'[Name] <> "")) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - "Sales": {"DateKey": "DateKey", "CustomerKey": "CustomerKey"}, - "Customer": {"Name": "Name", "CustomerKey": "CustomerKey"}, - }, - relationship_edges=[ - RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), - RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), - ], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(DISTINCT CASE WHEN (Customer.Name <> '') THEN Customer.CustomerKey END) AS Customers" in sql - assert ( - "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" - in sql - ) - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_summarizecolumns_keepfilters_filter(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - KEEPFILTERS(FILTER('Sales', 'Sales'[ProductKey] = 1)), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "WHERE (Sales.ProductKey = 1)" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarizecolumns_filter_with_keepfilters_wrapped_base_table(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - FILTER( - KEEPFILTERS(FILTER('Sales', 'Sales'[Amount] > 0)), - 'Sales'[Amount] > 10 - ), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (Sales.Amount > 0) AND (Sales.Amount > 10)" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarizecolumns_filter_with_nonvisual_wrapped_base_table(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - FILTER( - NONVISUAL(FILTER('Sales', 'Sales'[Amount] > 0)), - 'Sales'[Amount] > 10 - ), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (Sales.Amount > 0) AND (Sales.Amount > 10)" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarizecolumns_all_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - ALL('Sales'), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarizecolumns_allnoblankrow_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - ALLNOBLANKROW('Sales'), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarizecolumns_groupby_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - GROUPBY( - FILTER('Sales', 'Sales'[Amount] > 0), - 'Sales'[Category] - ), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "WHERE (Sales.Amount > 0)" in sql - assert "GROUP BY Sales.Category" in sql - - -def test_translate_query_summarizecolumns_datatable_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - DATATABLE( - "k", INTEGER, - "v", STRING, - {{1, "a"}} - ), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "GROUP BY Sales.Category" in sql - - -def test_translate_query_summarizecolumns_topnskip_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - TOPNSKIP( - 2, - 0, - FILTER('Sales', 'Sales'[Amount] > 0), - 'Sales'[Amount], DESC - ), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "WHERE (Sales.Amount > 0)" in sql - assert "GROUP BY Sales.Category" in sql - - -def test_translate_query_summarizecolumns_rejects_scalar_function_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - ABS(1), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZECOLUMNS argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_summarizecolumns_rejects_unknown_identifier_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - UnknownIdentifier, - "Rows", COUNTROWS('Sales') - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZECOLUMNS argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_summarizecolumns_rejects_unknown_table_function_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - UNKNOWNTABLEFN('Sales'), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZECOLUMNS argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_unknown_table_function_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - UNKNOWNTABLEFN('Sales') - """ - ) - - with pytest.raises(DaxTranslationError, match="Unsupported table function 'UNKNOWNTABLEFN'"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_unknown_scalar_function_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", UNKNOWNFUNC(1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="Unsupported scalar function 'UNKNOWNFUNC'"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -@pytest.mark.parametrize( - ("scalar_expr", "func_name"), - [ - ("SELECTEDMEASURE()", "SELECTEDMEASURE"), - ("SELECTEDMEASURENAME()", "SELECTEDMEASURENAME"), - ("SELECTEDMEASUREFORMATSTRING()", "SELECTEDMEASUREFORMATSTRING"), - ("ISSELECTEDMEASURE(1)", "ISSELECTEDMEASURE"), - ], -) -def test_translate_query_calc_group_scalar_function_error_is_explicit(scalar_expr: str, func_name: str): - query = _parse_query( - f""" - EVALUATE - ROW("x", {scalar_expr}) - """ - ) - - with pytest.raises(DaxTranslationError, match=f"{func_name} is only supported in calculation group expressions"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_detailrows_table_function_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - DETAILROWS('Sales') - """ - ) - - with pytest.raises(DaxTranslationError, match="DETAILROWS is only supported in model detail rows expressions"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_substitutewithindex_table_expression(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX('Sales', "Idx", VALUES('Sales'[Category]), 'Sales'[Category]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT l.Amount, i.__substitutewithindex_rank AS Idx FROM (SELECT * FROM Sales) AS l LEFT JOIN (" in sql - assert "DENSE_RANK() OVER (ORDER BY i0.Category ASC) AS __substitutewithindex_rank" in sql - assert "ON l.Category IS NOT DISTINCT FROM i.Category" in sql - - -def test_translate_query_substitutewithindex_table_expression_desc_order(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX('Sales', "Idx", VALUES('Sales'[Category]), 'Sales'[Category], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "DENSE_RANK() OVER (ORDER BY i0.Category DESC) AS __substitutewithindex_rank" in sql - - -def test_translate_query_substitutewithindex_table_expression_multi_order_keys(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - 'Sales', - "Idx", - SUMMARIZE('Sales', 'Sales'[Category], 'Sales'[Amount]), - 'Sales'[Category], ASC, - 'Sales'[Amount], DESC - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "DENSE_RANK() OVER (ORDER BY i0.Category ASC, i0.Amount DESC) AS __substitutewithindex_rank" in sql - - -def test_translate_query_substitutewithindex_requires_exactly_one_index_table_argument(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX('Sales', "Idx", 'Sales'[Category], 'Sales'[Category], ASC) - """ - ) - - with pytest.raises(DaxTranslationError, match="SUBSTITUTEWITHINDEX requires exactly one index table argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - -def test_translate_query_substitutewithindex_supports_wrapped_index_table_argument(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - 'Sales', - "Idx", - CALCULATETABLE( - VALUES('Sales'[Category]), - 'Sales'[Amount] > 100 - ), - 'Sales'[Category] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Sales.Category FROM Sales" in sql - assert "WHERE (Amount > 100)" in sql - assert "DENSE_RANK() OVER (ORDER BY i0.Category ASC) AS __substitutewithindex_rank" in sql - - -def test_translate_query_substitutewithindex_rejects_order_by_column_not_from_index_table(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - 'Sales', - "Idx", - VALUES('Sales'[Category]), - 'Sales'[Amount], - ASC - ) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="SUBSTITUTEWITHINDEX ORDER BY expressions must reference columns from the index table argument", - ): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - -def test_translate_query_substitutewithindex_supports_cross_table_order_by_column_from_index_table(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - VALUES('Sales'[Amount]), - "Idx", - CROSSJOIN('Sales', 'Products'), - 'Products'[Weight], - DESC - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "DENSE_RANK() OVER (ORDER BY i0.Weight DESC) AS __substitutewithindex_rank" in sql - - -def test_translate_query_substitutewithindex_rejects_ambiguous_common_column_in_source_table(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - CROSSJOIN('Sales', 'Products'), - "Idx", - VALUES('Sales'[ProductKey]), - 'Sales'[ProductKey] - ) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="SUBSTITUTEWITHINDEX source table has ambiguous common column 'ProductKey'", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, - "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, - }, - ) - - -def test_translate_query_substitutewithindex_rejects_ambiguous_order_by_column_in_index_table_qualified_ref(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - VALUES('Sales'[Amount]), - "Idx", - CROSSJOIN('Sales', 'Products'), - 'Products'[ProductKey] - ) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="SUBSTITUTEWITHINDEX ORDER BY column 'ProductKey' is ambiguous in index table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, - "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, - }, - ) - - -def test_translate_query_substitutewithindex_rejects_ambiguous_common_column_in_index_table(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - VALUES('Sales'[ProductKey]), - "Idx", - CROSSJOIN('Sales', 'Products'), - 'Sales'[ProductKey] - ) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="SUBSTITUTEWITHINDEX index table has ambiguous common column 'ProductKey'", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, - "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, - }, - ) - - -def test_translate_query_substitutewithindex_rejects_ambiguous_order_by_column_in_index_table(): - query = _parse_query( - """ - EVALUATE - SUBSTITUTEWITHINDEX( - VALUES('Sales'[Amount]), - "Idx", - CROSSJOIN('Sales', 'Products'), - 'Sales'[ProductKey] - ) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="SUBSTITUTEWITHINDEX ORDER BY column 'ProductKey' is ambiguous in index table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, - "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, - }, - ) - - -def test_translate_query_substitutewithindex_in_scalar_context_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", SUBSTITUTEWITHINDEX('Sales', "Idx", 'Sales'[Category], 'Sales')) - """ - ) - - with pytest.raises( - DaxTranslationError, match="SUBSTITUTEWITHINDEX returns a table and is not valid in scalar context" - ): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_evaluate_calculatetable_substitutewithindex_preserves_underlying_filters(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - 'Sales', - SUBSTITUTEWITHINDEX( - FILTER('Sales', 'Sales'[Amount] > 100), - "Idx", - VALUES('Sales'[Category]), - 'Sales'[Category] - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Category": "Category"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql - - -@pytest.mark.parametrize( - ("table_expr", "func_name"), - [ - ("SELECTEDMEASURE()", "SELECTEDMEASURE"), - ("SELECTEDMEASURENAME()", "SELECTEDMEASURENAME"), - ("SELECTEDMEASUREFORMATSTRING()", "SELECTEDMEASUREFORMATSTRING"), - ("ISSELECTEDMEASURE(1)", "ISSELECTEDMEASURE"), - ], -) -def test_translate_query_calc_group_scalar_function_in_table_context_error_is_explicit(table_expr: str, func_name: str): - query = _parse_query( - f""" - EVALUATE - {table_expr} - """ - ) - - with pytest.raises(DaxTranslationError, match=f"{func_name} is only supported in calculation group expressions"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_row_table_function_in_scalar_context_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", INTERSECT({1, 2}, {2, 3})) - """ - ) - - with pytest.raises(DaxTranslationError, match="INTERSECT returns a table and is not valid in scalar context"): - translate_dax_query(query) - - -def test_translate_query_row_keepcolumns_table_function_in_scalar_context_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", KEEPCOLUMNS('Sales', 'Sales'[ProductKey])) - """ - ) - - with pytest.raises(DaxTranslationError, match="KEEPCOLUMNS returns a table and is not valid in scalar context"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - -def test_translate_query_row_calculate_filter_only_function_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", USERELATIONSHIP('sales'[product_key], 'products'[product_key])) - """ - ) - - with pytest.raises(DaxTranslationError, match="USERELATIONSHIP is only valid in CALCULATE filter arguments"): - translate_dax_query(query) - - -def test_translate_query_row_crossfilter_calculate_filter_only_function_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)) - """ - ) - - with pytest.raises(DaxTranslationError, match="CROSSFILTER is only valid in CALCULATE filter arguments"): - translate_dax_query(query) - - -def test_translate_query_row_previousweek_table_function_in_scalar_context_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", PREVIOUSWEEK('Date'[DateKey])) - """ - ) - - with pytest.raises(DaxTranslationError, match="PREVIOUSWEEK returns a table and is not valid in scalar context"): - translate_dax_query( - query, - column_sql_by_table={"Date": {"DateKey": "DateKey"}}, - time_dimensions_by_table={"Date": {"DateKey"}}, - ) - - -def test_translate_query_row_nextweek_table_function_in_scalar_context_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", NEXTWEEK('Date'[DateKey])) - """ - ) - - with pytest.raises(DaxTranslationError, match="NEXTWEEK returns a table and is not valid in scalar context"): - translate_dax_query( - query, - column_sql_by_table={"Date": {"DateKey": "DateKey"}}, - time_dimensions_by_table={"Date": {"DateKey"}}, - ) - - -def test_translate_query_row_rollup_wrapper_in_scalar_context_error_is_explicit(): - query = _parse_query( - """ - EVALUATE - ROW("x", ROLLUP('Sales'[ProductKey])) - """ - ) - - with pytest.raises(DaxTranslationError, match="ROLLUP returns a table and is not valid in scalar context"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - -@pytest.mark.parametrize( - ("func_expr", "func_name"), - [ - ("ROLLUPGROUP('Sales'[ProductKey])", "ROLLUPGROUP"), - ("ROLLUPADDISSUBTOTAL(\"IsTotal\", 'Sales'[ProductKey])", "ROLLUPADDISSUBTOTAL"), - ("ROLLUPISSUBTOTAL(\"IsTotal\", 'Sales'[ProductKey])", "ROLLUPISSUBTOTAL"), - ], -) -def test_translate_query_row_rollup_wrapper_table_functions_in_scalar_context_error_is_explicit( - func_expr: str, func_name: str -): - query = _parse_query( - f""" - EVALUATE - ROW("x", {func_expr}) - """ - ) - - with pytest.raises(DaxTranslationError, match=rf"{func_name} returns a table and is not valid in scalar context"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - -def test_translate_query_sum_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", SUM(1, 2)) - """ - ) - - with pytest.raises(DaxTranslationError, match="SUM supports exactly one argument"): - translate_dax_query(query) - - -def test_translate_query_countrows_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", COUNTROWS('Sales', 'Sales')) - """ - ) - - with pytest.raises(DaxTranslationError, match="COUNTROWS supports at most one argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_divide_rejects_more_than_three_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", DIVIDE(10, 2, 0, 1)) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="DIVIDE supports at most numerator, denominator, and alternate result arguments", - ): - translate_dax_query(query) - - -def test_translate_query_and_or_reject_more_than_two_arguments(): - and_query = _parse_query( - """ - EVALUATE - ROW("x", AND(TRUE(), FALSE(), TRUE())) - """ - ) - or_query = _parse_query( - """ - EVALUATE - ROW("x", OR(TRUE(), FALSE(), TRUE())) - """ - ) - - with pytest.raises(DaxTranslationError, match="AND supports exactly two arguments"): - translate_dax_query(and_query) - - with pytest.raises(DaxTranslationError, match="OR supports exactly two arguments"): - translate_dax_query(or_query) - - -def test_translate_query_weekday_rejects_more_than_two_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", WEEKDAY("2024-02-01", 2, 3)) - """ - ) - - with pytest.raises(DaxTranslationError, match="WEEKDAY supports at most date and return_type arguments"): - translate_dax_query(query) - - -def test_translate_query_year_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", YEAR("2024-02-01", 2)) - """ - ) - - with pytest.raises(DaxTranslationError, match="YEAR supports exactly one argument"): - translate_dax_query(query) - - -def test_translate_query_left_rejects_more_than_two_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", LEFT("abcd", 2, 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="LEFT supports at most text and num_chars arguments"): - translate_dax_query(query) - - -def test_translate_query_date_ctor_rejects_more_than_three_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", DATE(2024, 2, 1, 3)) - """ - ) - - with pytest.raises(DaxTranslationError, match="DATE requires year, month, and day arguments"): - translate_dax_query(query) - - -def test_translate_query_value_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", VALUE("10", 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="VALUE supports exactly one argument"): - translate_dax_query(query) - - -def test_translate_query_concatenate_rejects_more_than_two_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", CONCATENATE("a", "b", "c")) - """ - ) - - with pytest.raises(DaxTranslationError, match="CONCATENATE supports exactly two arguments"): - translate_dax_query(query) - - -def test_translate_query_concatenatex_requires_table_and_expression_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", CONCATENATEX('Sales')) - """ - ) - - with pytest.raises(DaxTranslationError, match="CONCATENATEX requires table and expression arguments"): - translate_dax_query(query) - - -def test_translate_query_selectedvalue_rejects_more_than_two_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", SELECTEDVALUE('Sales'[Category], "a", "b")) - """ - ) - - with pytest.raises( - DaxTranslationError, match="SELECTEDVALUE supports at most column and alternate_result arguments" - ): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_hasonevalue_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", HASONEVALUE('Sales'[Category], 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="HASONEVALUE/HASONEFILTER supports exactly one argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_firstnonblank_rejects_more_than_two_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", FIRSTNONBLANK('Sales'[Category], 'Sales'[Category], 1)) - """ - ) - - with pytest.raises( - DaxTranslationError, match="FIRSTNONBLANK/LASTNONBLANK supports exactly column and expression arguments" - ): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_isfiltered_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", ISFILTERED('Sales'[Category], 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="ISFILTERED/ISCROSSFILTERED supports exactly one argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_nameof_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", NAMEOF('Sales'[Category], 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="NAMEOF supports exactly one argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - ) - - -def test_translate_query_today_rand_reject_arguments(): - today_query = _parse_query( - """ - EVALUATE - ROW("x", TODAY(1)) - """ - ) - rand_query = _parse_query( - """ - EVALUATE - ROW("x", RAND(1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="TODAY does not take arguments"): - translate_dax_query(today_query) - - with pytest.raises(DaxTranslationError, match="RAND does not take arguments"): - translate_dax_query(rand_query) - - -def test_translate_query_round_rejects_more_than_two_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", ROUND(1, 2, 3)) - """ - ) - - with pytest.raises(DaxTranslationError, match="ROUND requires number and num_digits arguments"): - translate_dax_query(query) - - -def test_translate_query_upper_isblank_reject_extra_arguments(): - upper_query = _parse_query( - """ - EVALUATE - ROW("x", UPPER("abc", "x")) - """ - ) - isblank_query = _parse_query( - """ - EVALUATE - ROW("x", ISBLANK(1, 2)) - """ - ) - - with pytest.raises(DaxTranslationError, match="UPPER supports exactly one argument"): - translate_dax_query(upper_query) - - with pytest.raises(DaxTranslationError, match="ISBLANK supports exactly one argument"): - translate_dax_query(isblank_query) - - -def test_translate_query_coalesce(): - query = _parse_query( - """ - EVALUATE - ROW("x", COALESCE('Sales'[Amount], 0)) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "COALESCE(Amount, 0) AS x" in sql - - -def test_translate_query_coalesce_requires_at_least_two_arguments(): - query = _parse_query( - """ - EVALUATE - ROW("x", COALESCE('Sales'[Amount])) - """ - ) - - with pytest.raises(DaxTranslationError, match="COALESCE requires at least two arguments"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_switch_boolean_predicate_form(): - query = _parse_query( - """ - EVALUATE - ROW("x", SWITCH(TRUE(), 'Sales'[Amount] > 0, 1, 0)) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN (Amount > 0) THEN 1 ELSE 0 END AS x" in sql - - -def test_translate_query_switch_requires_expression_and_value_result_pair(): - query = _parse_query( - """ - EVALUATE - ROW("x", SWITCH('Sales'[Amount])) - """ - ) - - with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - query_missing_result = _parse_query( - """ - EVALUATE - ROW("x", SWITCH('Sales'[Amount], 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): - translate_dax_query( - query_missing_result, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_isempty_table_expression(): - query = _parse_query( - """ - EVALUATE - ROW("x", ISEMPTY(FILTER('Sales', 'Sales'[Amount] > 0))) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "NOT EXISTS (SELECT 1 FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t) AS x" in sql - - -def test_translate_query_isempty_rejects_more_than_one_argument(): - query = _parse_query( - """ - EVALUATE - ROW("x", ISEMPTY('Sales', 'Sales')) - """ - ) - - with pytest.raises(DaxTranslationError, match="ISEMPTY supports exactly one argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_summarizecolumns_rollupgroup_group_by(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - ROLLUPGROUP('Sales'[ProductKey]), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarize_rejects_scalar_group_by_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZE( - 'Sales', - ABS(1), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="Unsupported SUMMARIZE group-by argument"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_summarizecolumns_rollupaddissubtotal_group_by(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - ROLLUPADDISSUBTOTAL('Sales'[ProductKey], "is_subtotal"), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarizecolumns_unrelated_tables_cross_join(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - 'Products'[Category] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey"}, - "Products": {"Category": "Category"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "FROM Sales CROSS JOIN Products" in sql - assert "GROUP BY Sales.ProductKey, Products.Category" in sql - - -def test_translate_query_summarizecolumns_cross_join_with_disconnected_component_relationship(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'A'[Id], - 'X'[XId], - 'Y'[YLabel] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "A": {"Id": "Id"}, - "X": {"XId": "XId", "YId": "YId"}, - "Y": {"YId": "YId", "YLabel": "YLabel"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="X", - from_column="YId", - to_table="Y", - to_column="YId", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "FROM A CROSS JOIN X LEFT JOIN Y ON X.YId = Y.YId" in sql - assert "GROUP BY A.Id, X.XId, Y.YLabel" in sql - - -def test_translate_query_summarizecolumns_treatas_filter(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - TREATAS({1, 2}, 'Sales'[ProductKey]), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "WHERE (Sales.ProductKey IN (1, 2))" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_evaluate_treatas_table_expression(): - query = _parse_query( - """ - EVALUATE - TREATAS({1, 2}, 'Sales'[ProductKey]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Sales.ProductKey AS ProductKey FROM Sales WHERE (Sales.ProductKey IN (1, 2))" in sql - - -def test_translate_query_evaluate_treatas_table_expression_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS(SELECTCOLUMNS('Sales', "k", 'Sales'[ProductKey], "q", 'Sales'[Quantity]), 'Sales'[ProductKey]) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity"}}, - ) - - -def test_translate_query_evaluate_treatas_filter_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS(FILTER('Sales', 'Sales'[Amount] > 10), 'Sales'[ProductKey]) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_treatas_values_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS( - FILTER(VALUES('Sales'[ProductKey]), 'Sales'[ProductKey] > 1), - 'Sales'[ProductKey], - 'Sales'[Quantity] - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity"}}, - ) - - -def test_translate_query_evaluate_treatas_union_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS(UNION('Sales', 'Sales'), 'Sales'[ProductKey]) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_treatas_naturalinnerjoin_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS(NATURALINNERJOIN('Sales', 'Sales'), 'Sales'[ProductKey]) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_treatas_naturalleftouterjoin_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS(NATURALLEFTOUTERJOIN('Sales', 'Sales'), 'Sales'[ProductKey]) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_treatas_renamecolumns_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS( - RENAMECOLUMNS('Sales', 'Sales'[ProductKey], "k"), - 'Sales'[ProductKey], - 'Sales'[Quantity] - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity", "Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_treatas_removecolumns_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS( - REMOVECOLUMNS('Sales', 'Sales'[Amount], 'Sales'[Quantity]), - 'Sales'[ProductKey], - 'Sales'[Quantity] - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity", "Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_treatas_keepcolumns_wrapper_rejects_width_mismatch(): - query = _parse_query( - """ - EVALUATE - TREATAS( - KEEPCOLUMNS('Sales', 'Sales'[ProductKey], 'Sales'[Quantity]), - 'Sales'[ProductKey] - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity", "Amount": "Amount"}}, - ) - - -def test_translate_query_summarizecolumns_datesbetween_filter(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - DATESBETWEEN('Sales'[OrderDate], "2024-01-01", "2024-12-31"), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "OrderDate": "OrderDate"}}, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (Sales.OrderDate >= '2024-01-01' AND Sales.OrderDate <= '2024-12-31')" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_evaluate_datesbetween_cross_table_start_bound_joins_referenced_table(): - query = _parse_query( - """ - EVALUATE - DATESBETWEEN('Date'[DateKey], 'Sales'[DateKey], "2024-12-31") - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey"}, - "Sales": {"DateKey": "DateKey"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="DateKey", - to_table="Date", - to_column="DateKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT Date.* FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql - assert "WHERE (Date.DateKey >= Sales.DateKey AND Date.DateKey <= '2024-12-31')" in sql - assert "Sales" in translation.evaluates[0].required_models - - -def test_translate_query_summarizecolumns_nonvisual_treatas_filter(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - NONVISUAL(TREATAS({1}, 'Sales'[ProductKey])), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (Sales.ProductKey IN (1))" in sql - assert "GROUP BY Sales.ProductKey" in sql - - -def test_translate_query_summarizecolumns_ignore_measure_expression(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - "Rows", IGNORE(COUNTROWS('Sales')) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "COUNT(*) AS Rows" in sql - assert "IGNORE(" not in sql - - -def test_translate_query_summarize_order_by(): - query = _parse_query( - """ - EVALUATE - SUMMARIZE( - 'Sales', - 'Sales'[Amount], - "Rows", COUNTROWS('Sales') - ) - ORDER BY 'Sales'[Amount] DESC - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - assert len(translation.evaluates) == 1 - sql = translation.evaluates[0].sql - assert "SELECT Sales.Amount, COUNT(*) AS Rows FROM Sales GROUP BY Sales.Amount" in sql - assert "ORDER BY q.Amount DESC" in sql - - -def test_translate_query_summarize_filter_table_expression_order_by(): - query = _parse_query( - """ - EVALUATE - SUMMARIZE( - FILTER('Sales', 'Sales'[Amount] > 10), - 'Sales'[Amount], - "Rows", COUNTROWS('Sales') - ) - ORDER BY 'Sales'[Amount] DESC - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - assert len(translation.evaluates) == 1 - sql = translation.evaluates[0].sql - assert "FROM (SELECT * FROM Sales WHERE (Amount > 10)) AS t" in sql - assert "SELECT Amount, COUNT(*) AS Rows" in sql - assert "GROUP BY Amount" in sql - assert "ORDER BY q.Amount DESC" in sql - - -def test_translate_query_summarize_wrapped_multitable_row_group_by_bracket_alias(): - query = _parse_query( - """ - EVALUATE - SUMMARIZE( - ROW("x", 'Sales'[Amount], "d", 'Date'[DateKey]), - [x] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT x FROM (SELECT Amount AS x, Date.DateKey AS d FROM Sales CROSS JOIN Date) AS t GROUP BY x" in sql - assert "Sales" in translation.evaluates[0].required_models - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_summarize_wrapped_multitable_row_group_by_identifier_alias(): - query = _parse_query( - """ - EVALUATE - SUMMARIZE( - ROW("x", 'Sales'[Amount], "d", 'Date'[DateKey]), - x - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT x FROM (SELECT Amount AS x, Date.DateKey AS d FROM Sales CROSS JOIN Date) AS t GROUP BY x" in sql - assert "Sales" in translation.evaluates[0].required_models - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_summarize_filter_table_expression_cross_table_group_by(): - query = _parse_query( - """ - EVALUATE - SUMMARIZE( - FILTER('Sales', 'Sales'[Amount] > 10), - 'Date'[Fiscal Year], - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "FROM (SELECT * FROM Sales WHERE (Amount > 10)) AS t LEFT JOIN Date ON t.DateKey = Date.DateKey" in sql - assert "SELECT Date.FiscalYear, COUNT(*) AS Rows" in sql - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_summarize_filter_table_expression_cross_join_disconnected_group_by(): - query = _parse_query( - """ - EVALUATE - SUMMARIZE( - FILTER('Sales', 'Sales'[Amount] > 10), - 'Product'[Category], - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Product": {"Category": "Category"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "FROM (SELECT * FROM Sales WHERE (Amount > 10)) AS t CROSS JOIN Product" in sql - assert "SELECT Product.Category, COUNT(*) AS Rows" in sql - assert "GROUP BY Product.Category" in sql - - -def test_translate_query_define_measure_inlines_bracket_reference(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales Order'[Orders] = DISTINCTCOUNT('Sales Order'[Sales Order]) - EVALUATE - SUMMARIZECOLUMNS( - 'Sales Order'[Sales Order], - "Orders", [Orders] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales Order": {"Sales Order": '"Sales Order"'}}, - measure_names_by_table={"Sales Order": {"Orders"}}, - ) - - assert len(translation.evaluates) == 1 - sql = translation.evaluates[0].sql - assert 'COUNT(DISTINCT "Sales Order") AS Orders' in sql - - -def test_translate_query_define_measure_with_identifier_table_ref(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[Customers] = COUNTROWS(Customer) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - "Customers", [Customers] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"SalesOrderLineKey": "SalesOrderLineKey"}, - "Date": {"FiscalYear": "FiscalYear"}, - "Customer": {"CustomerKey": "CustomerKey"}, - }, - measure_names_by_table={"Sales": {"Customers"}}, - ) - - assert len(translation.evaluates) == 1 - sql = translation.evaluates[0].sql - assert "(SELECT COUNT(*) FROM Customer) AS Customers" in sql - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_define_measure_with_identifier_table_ref_uses_relationship_group_context(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[Customers] = COUNTROWS(Customer) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - "Customers", [Customers] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"SalesOrderLineKey": "SalesOrderLineKey", "DateKey": "DateKey", "CustomerKey": "CustomerKey"}, - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - "Customer": {"Name": "Name", "CustomerKey": "CustomerKey"}, - }, - measure_names_by_table={"Sales": {"Customers"}}, - relationship_edges=[ - RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), - RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), - ], - ) - - assert len(translation.evaluates) == 1 - sql = translation.evaluates[0].sql - assert "COUNT(Customer.CustomerKey) AS Customers" in sql - assert ( - "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey LEFT JOIN Customer ON Sales.CustomerKey = Customer.CustomerKey" - in sql - ) - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_define_measure_with_calculate_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Pick a sales measure'[Customers] = CALCULATE( - COUNTROWS(Customer), - FILTER( - 'Sales', - 'Sales'[Amount] > 0 - ) - ) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Customers", [Customers] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey", "CustomerKey": "CustomerKey"}, - "Customer": {"CustomerKey": "CustomerKey"}, - }, - measure_names_by_table={"Pick a sales measure": {"Customers"}}, - relationship_edges=[ - RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), - RelationshipEdge("Sales", "CustomerKey", "Customer", "CustomerKey"), - ], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(CASE WHEN" in sql - assert "Sales.Amount > 0" in sql - assert "AS Customers" in sql - - -def test_translate_query_define_measure_calculate_values_cross_table_filter_candidate(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[SalesByDateContext] = CALCULATE( - SUM('Sales'[Amount]), - VALUES('Date') - ) - EVALUATE - ROW("Value", [SalesByDateContext]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"SalesByDateContext"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - assert len(translation.evaluates) == 1 - assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_define_measure_calculate_table_ref_cross_table_filter_candidate(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[SalesByDateTableContext] = CALCULATE( - SUM('Sales'[Amount]), - 'Date' - ) - EVALUATE - ROW("Value", [SalesByDateTableContext]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"SalesByDateTableContext"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - assert len(translation.evaluates) == 1 - assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_define_measure_calculate_filters_cross_table_filter_candidate(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[SalesByDateFilterContext] = CALCULATE( - SUM('Sales'[Amount]), - FILTERS('Date'[DateKey]) - ) - EVALUATE - ROW("Value", [SalesByDateFilterContext]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"SalesByDateFilterContext"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - assert len(translation.evaluates) == 1 - assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_define_measure_calculate_sameperiodlastyear_cross_table_time_column(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[Prev] = CALCULATE( - SUM('Sales'[Amount]), - SAMEPERIODLASTYEAR('Date'[DateKey]) - ) - EVALUATE - ROW("Value", [Prev]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"Prev"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - time_dimensions_by_table={"Sales": {"DateKey"}, "Date": {"DateKey"}}, - ) - - assert len(translation.evaluates) == 1 - assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_define_measure_totalytd_cross_table_time_column_and_table_filter(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[YTD] = TOTALYTD( - SUM('Sales'[Amount]), - 'Date'[DateKey], - 'Date' - ) - EVALUATE - ROW("Value", [YTD]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"YTD"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - time_dimensions_by_table={"Sales": {"DateKey"}, "Date": {"DateKey"}}, - ) - - assert len(translation.evaluates) == 1 - assert "SUM(Sales.Amount) AS Value" in translation.evaluates[0].sql - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_define_measure_with_countrows_datesbetween_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[RowsInRange] = COUNTROWS( - DATESBETWEEN('Sales'[OrderDate], "2024-01-01", "2024-12-31") - ) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "RowsInRange", [RowsInRange] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"OrderDate": "OrderDate", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"RowsInRange"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(CASE WHEN (Sales.OrderDate >= '2024-01-01' AND Sales.OrderDate <= '2024-12-31') THEN 1 END)" in sql - assert "AS RowsInRange" in sql - - -def test_translate_query_define_measure_with_countrows_filters_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[SelectedProducts] = COUNTROWS(FILTERS('Sales'[ProductKey])) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "SelectedProducts", [SelectedProducts] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"SelectedProducts"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(DISTINCT Sales.ProductKey) AS SelectedProducts" in sql - - -def test_translate_query_define_measure_with_countrows_cross_table_filters_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[SelectedDates] = COUNTROWS(FILTERS('Date'[DateKey])) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "SelectedDates", [SelectedDates] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"SelectedDates"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(DISTINCT Date.DateKey) AS SelectedDates" in sql - - -def test_translate_query_define_measure_with_countrows_all_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[AllRows] = COUNTROWS(ALL('Sales')) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "AllRows", [AllRows] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"AllRows"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "(SELECT COUNT(*) FROM (SELECT * FROM Sales) AS t) AS AllRows" in sql - - -def test_translate_query_define_measure_with_approximatedistinctcount_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[ApproxProducts] = APPROXIMATEDISTINCTCOUNT('Sales'[ProductKey]) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "ApproxProducts", [ApproxProducts] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"ApproxProducts"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(DISTINCT Sales.ProductKey) AS ApproxProducts" in sql - - -def test_translate_query_define_measure_with_sumx_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[PositiveAmount] = SUMX( - FILTER('Sales', 'Sales'[Amount] > 0), - 'Sales'[Amount] - ) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "PositiveAmount", [PositiveAmount] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"PositiveAmount"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SUM(CASE WHEN" in sql - assert "Sales.Amount > 0" in sql - assert "THEN Sales.Amount ELSE NULL END) AS PositiveAmount" in sql - - -def test_translate_query_define_measure_with_avgx_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[AvgPositiveAmount] = AVGX( - FILTER('Sales', 'Sales'[Amount] > 0), - 'Sales'[Amount] - ) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "AvgPositiveAmount", [AvgPositiveAmount] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"AvgPositiveAmount"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "AVG(CASE WHEN" in sql - assert "Sales.Amount > 0" in sql - assert "THEN Sales.Amount ELSE NULL END) AS AvgPositiveAmount" in sql - - -def test_translate_query_define_measure_with_countx_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[CountPositiveAmount] = COUNTX( - FILTER('Sales', 'Sales'[Amount] > 0), - 'Sales'[Amount] - ) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "CountPositiveAmount", [CountPositiveAmount] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"CountPositiveAmount"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(CASE WHEN" in sql - assert "Sales.Amount > 0" in sql - assert "THEN Sales.Amount END) AS CountPositiveAmount" in sql - - -def test_translate_query_define_measure_with_maxx_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[MaxPositiveAmount] = MAXX( - FILTER('Sales', 'Sales'[Amount] > 0), - 'Sales'[Amount] - ) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "MaxPositiveAmount", [MaxPositiveAmount] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"MaxPositiveAmount"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "MAX(CASE WHEN" in sql - assert "Sales.Amount > 0" in sql - assert "THEN Sales.Amount ELSE NULL END) AS MaxPositiveAmount" in sql - - -def test_translate_query_define_measure_with_countblank_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[BlankProductKeys] = COUNTBLANK('Sales'[ProductKey]) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "BlankProductKeys", [BlankProductKeys] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"BlankProductKeys"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "COUNT(CASE WHEN Sales.ProductKey IS NULL THEN 1 END) AS BlankProductKeys" in sql - - -def test_translate_query_define_measure_with_selectedvalue_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[SelectedProduct] = SELECTEDVALUE('Sales'[ProductKey], -1) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "SelectedProduct", [SelectedProduct] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"SelectedProduct"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert ( - "CASE WHEN COUNT(DISTINCT Sales.ProductKey) = 1 THEN MIN(Sales.ProductKey) ELSE -1 END AS SelectedProduct" - in sql - ) - - -def test_translate_query_define_measure_with_firstnonblank_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[FirstProductWithAmount] = FIRSTNONBLANK('Sales'[ProductKey], 'Sales'[Amount]) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "FirstProductWithAmount", [FirstProductWithAmount] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"FirstProductWithAmount"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert ( - "MIN(CASE WHEN Sales.Amount IS NOT NULL THEN Sales.ProductKey ELSE NULL END) AS FirstProductWithAmount" in sql - ) - - -def test_translate_query_define_measure_with_firstdate_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[FirstOrderDate] = FIRSTDATE('Sales'[OrderDate]) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "FirstOrderDate", [FirstOrderDate] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"OrderDate": "OrderDate", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"FirstOrderDate"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "MIN(Sales.OrderDate) AS FirstOrderDate" in sql - - -def test_translate_query_define_measure_with_isinscope_true_when_grouped(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[InScopeProduct] = ISINSCOPE('Sales'[ProductKey]) - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - "InScopeProduct", [InScopeProduct] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - measure_names_by_table={"Sales": {"InScopeProduct"}}, - ) - - sql = translation.evaluates[0].sql - assert "TRUE AS InScopeProduct" in sql - - -def test_translate_query_define_measure_with_isinscope_false_when_not_grouped(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[InScopeProduct] = ISINSCOPE('Sales'[ProductKey]) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "InScopeProduct", [InScopeProduct] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"InScopeProduct"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "FALSE AS InScopeProduct" in sql - - -def test_translate_query_define_measure_with_isfiltered_true_when_filtered(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[ProductFiltered] = ISFILTERED('Sales'[ProductKey]) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - TREATAS({1}, 'Sales'[ProductKey]), - "ProductFiltered", [ProductFiltered] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"ProductKey": "ProductKey", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"ProductFiltered"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "TRUE AS ProductFiltered" in sql - - -def test_translate_query_selectcolumns_containsstring_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "HasOpen", - CONTAINSSTRING('Sales'[Status], "open") - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "(POSITION(LOWER('open') IN LOWER(Status)) > 0) AS HasOpen" in sql - - -def test_translate_query_selectcolumns_containsrow_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "HasKey", - CONTAINSROW(VALUES('Sales'[ProductKey]), 1) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert ( - "EXISTS (SELECT 1 FROM (SELECT DISTINCT Sales.ProductKey FROM Sales) AS t(c1) " - "WHERE t.c1 IS NOT DISTINCT FROM 1) AS HasKey" in sql - ) - - -def test_translate_query_row_containsrow_rejects_value_count_mismatch(): - query = _parse_query( - """ - EVALUATE - ROW( - "HasKey", - CONTAINSROW( - SELECTCOLUMNS('Sales', "k", 'Sales'[ProductKey], "q", 'Sales'[Quantity]), - 1 - ) - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="CONTAINSROW value argument count must match table column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Quantity": "Quantity"}}, - ) - - -def test_translate_query_row_containsrow_rejects_non_inferable_table_width(): - query = _parse_query( - """ - EVALUATE - ROW( - "HasKey", - CONTAINSROW('Sales', 1) - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="CONTAINSROW requires an inferable table column count"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {}}, - ) - - -def test_translate_query_selectcolumns_len_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusLen", - LEN('Sales'[Status]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "LENGTH(Status) AS StatusLen" in sql - - -def test_translate_query_selectcolumns_left_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusLeft", - LEFT('Sales'[Status], 3) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "SUBSTRING(Status, 1, GREATEST(3, 0)) AS StatusLeft" in sql - - -def test_translate_query_selectcolumns_right_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusRight", - RIGHT('Sales'[Status], 3) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(Status, GREATEST(LENGTH(Status) - 3 + 1, 1), 3) END" in sql - assert "AS StatusRight" in sql - - -def test_translate_query_selectcolumns_mid_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusMid", - MID('Sales'[Status], 2, 3) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(Status, GREATEST(2, 1), 3) END AS StatusMid" in sql - - -def test_translate_query_selectcolumns_replace_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusReplaced", - REPLACE('Sales'[Status], 2, 2, "xx") - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN GREATEST(2, 1) <= 1 THEN '' ELSE SUBSTRING(Status, 1, GREATEST(2, 1) - 1) END" in sql - assert "|| 'xx' || SUBSTRING(Status, GREATEST(2, 1) + GREATEST(2, 0))" in sql - assert "AS StatusReplaced" in sql - - -def test_translate_query_selectcolumns_substitute_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusSubstituted", - SUBSTITUTE('Sales'[Status], "ab", "xy") - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "REPLACE(Status, 'ab', 'xy') AS StatusSubstituted" in sql - - -def test_translate_query_selectcolumns_substitute_instance_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusSubstituted", - SUBSTITUTE('Sales'[Status], "ab", "xy", 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN 'ab' = '' THEN Status" in sql - assert "INSTR(SUBSTR(Status, (INSTR(Status, 'ab')) + LENGTH('ab')), 'ab')" in sql - assert "AS StatusSubstituted" in sql - - -def test_translate_query_selectcolumns_rept_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusRepeated", - REPT('Sales'[Status], 3) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "REPEAT(Status, GREATEST(CAST(FLOOR(3) AS BIGINT), 0)) AS StatusRepeated" in sql - - -def test_translate_query_selectcolumns_trim_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "StatusTrimmed", - TRIM('Sales'[Status]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Status": "Status"}}, - ) - - sql = translation.evaluates[0].sql - assert "TRIM(REGEXP_REPLACE(CAST(Status AS VARCHAR), ' +', ' ', 'g')) AS StatusTrimmed" in sql - - -def test_translate_query_selectcolumns_weekday_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "WeekdayNum", - WEEKDAY('Sales'[OrderDate], 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"OrderDate": "OrderDate"}}, - ) - - sql = translation.evaluates[0].sql - assert "(((EXTRACT(DOW FROM CAST(OrderDate AS DATE)) + 6) % 7) + 1) AS WeekdayNum" in sql - - -def test_translate_query_selectcolumns_format_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountText", - FORMAT('Sales'[Amount], "0.00") - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "CAST(Amount AS VARCHAR) AS AmountText" in sql - - -def test_translate_query_selectcolumns_cross_table_expression_joins_related_table(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "Category", - 'Product'[Category] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert ( - "SELECT Product.Category AS Category FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" - in sql - ) - - -def test_translate_query_addcolumns_cross_table_expression_joins_related_table(): - query = _parse_query( - """ - EVALUATE - ADDCOLUMNS( - 'Sales', - "Category", - 'Product'[Category] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert ( - "SELECT Sales.*, Product.Category AS Category FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" - in sql - ) - - -def test_translate_query_selectcolumns_wrapped_base_cross_table_expression_joins_related_table(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - FILTER('Sales', 'Sales'[Amount] > 0), - "Category", - 'Product'[Category] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert ( - "SELECT Product.Category AS Category FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" - in sql - ) - - -def test_translate_query_addcolumns_wrapped_base_cross_table_expression_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - ADDCOLUMNS( - FILTER('Sales', 'Sales'[Amount] > 0), - "Rate", - 'Tax'[Rate] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT t.*, Tax.Rate AS Rate FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t CROSS JOIN Tax" in sql - - -def test_translate_query_selectcolumns_rounddown_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountRoundedDown", - ROUNDDOWN('Sales'[Amount], 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN 2 >= 0 THEN SIGN(Amount) * FLOOR(ABS(Amount) * POWER(10, 2)) / POWER(10, 2) ELSE" in sql - assert "AS AmountRoundedDown" in sql - - -def test_translate_query_selectcolumns_round_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountRounded", - ROUND('Sales'[Amount], 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN 2 >= 0 THEN SIGN(Amount) * FLOOR(ABS(Amount) * POWER(10, 2) + 0.5) / POWER(10, 2) ELSE" in sql - assert "AS AmountRounded" in sql - - -def test_translate_query_selectcolumns_int_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountInt", - INT('Sales'[Amount]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "FLOOR(Amount) AS AmountInt" in sql - - -def test_translate_query_selectcolumns_trunc_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountTrunc", - TRUNC('Sales'[Amount], 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN 2 >= 0 THEN SIGN(Amount) * FLOOR(ABS(Amount) * POWER(10, 2)) / POWER(10, 2) ELSE" in sql - assert "AS AmountTrunc" in sql - - -def test_translate_query_selectcolumns_ceiling_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountCeiling", - CEILING('Sales'[Amount], 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "(CEIL(Amount / 2) * 2) AS AmountCeiling" in sql - - -def test_translate_query_selectcolumns_floor_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountFloor", - FLOOR('Sales'[Amount], 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "(FLOOR(Amount / 2) * 2) AS AmountFloor" in sql - - -def test_translate_query_selectcolumns_mround_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountMround", - MROUND('Sales'[Amount], 2) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "CASE WHEN 2 = 0 THEN 0 ELSE SIGN(Amount) * FLOOR((ABS(Amount) / ABS(2)) + 0.5) * ABS(2) END" in sql - assert "AS AmountMround" in sql - - -def test_translate_query_evaluate_generateseries(): - query = _parse_query( - """ - EVALUATE - GENERATESERIES(1, 5, 2) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "SELECT value FROM generate_series(1, 5, 2) AS gs(value)" in sql - - -def test_translate_query_evaluate_calendar_cross_table_aggregate_bounds(): - query = _parse_query( - """ - EVALUATE - CALENDAR(MIN('Sales'[DateKey]), MAX('Date'[DateKey])) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "CAST((SELECT MIN(Sales.DateKey) FROM Sales) AS DATE)" in sql - assert "CAST((SELECT MAX(Date.DateKey) FROM Date) AS DATE)" in sql - assert "Sales" in translation.evaluates[0].required_models - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_selectcolumns_evaluateandlog_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "Amt", - EVALUATEANDLOG('Sales'[Amount]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "Amount AS Amt" in sql - - -def test_translate_query_selectcolumns_lookupvalue_expression(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "Category", - LOOKUPVALUE('Product'[Category], 'Product'[ProductKey], 'Sales'[ProductKey], "unknown") - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey"}, - "Product": {"Category": "Category", "ProductKey": "ProductKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Product.Category FROM Product WHERE Product.ProductKey = Sales.ProductKey LIMIT 1" in sql - assert "AS Category" in sql - - -def test_translate_query_summarizecolumns_related_expression(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - "Category", RELATED('Product'[Category]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey"}, - "Product": {"Category": "Category", "ProductKey": "ProductKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "ProductKey", "Product", "ProductKey")], - ) - - sql = translation.evaluates[0].sql - assert "Product.Category AS Category" in sql - assert "JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql - - -def test_translate_query_evaluate_relatedtable_expression(): - query = _parse_query( - """ - EVALUATE - RELATEDTABLE('Sales') - """ - ) - - translation = translate_dax_query(query, column_sql_by_table={"Sales": {"Amount": "Amount"}}) - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales" in sql - - -def test_translate_query_selectcolumns_find_and_search_expressions(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "SearchPos", SEARCH("ab", 'Sales'[Sku], 1, -1), - "FindPos", FIND("AB", 'Sales'[Sku], 1, 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Sku": "Sku"}}, - ) - sql = translation.evaluates[0].sql - assert "POSITION(LOWER('ab') IN SUBSTRING(LOWER(Sku), 1))" in sql - assert "POSITION('AB' IN SUBSTRING(Sku, 1))" in sql - assert "AS SearchPos" in sql - assert "AS FindPos" in sql - - -def test_translate_query_selectcolumns_now_and_datepart_expressions(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "Y", YEAR('Sales'[OrderDate]), - "Q", QUARTER('Sales'[OrderDate]), - "NowTs", NOW(), - "UtcDate", UTCTODAY() - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"OrderDate": "OrderDate"}}, - ) - sql = translation.evaluates[0].sql - assert "EXTRACT(YEAR FROM CAST(OrderDate AS DATE)) AS Y" in sql - assert "EXTRACT(QUARTER FROM CAST(OrderDate AS DATE)) AS Q" in sql - assert "CURRENT_TIMESTAMP AS NowTs" in sql - assert "CURRENT_DATE AS UtcDate" in sql - - -def test_translate_query_selectcolumns_value_and_concatenate_expressions(): - query = _parse_query( - """ - EVALUATE - SELECTCOLUMNS( - 'Sales', - "AmountNum", VALUE('Sales'[AmountText]), - "SkuTag", CONCATENATE('Sales'[Sku], "-X") - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"AmountText": "AmountText", "Sku": "Sku"}}, - ) - sql = translation.evaluates[0].sql - assert "CAST(AmountText AS DOUBLE) AS AmountNum" in sql - assert "(Sku || '-X') AS SkuTag" in sql - - -def test_translate_query_row_concatenatex_expression(): - query = _parse_query( - """ - EVALUATE - ROW( - "SkuList", - CONCATENATEX('Sales', 'Sales'[Sku], ",", 'Sales'[Amount], DESC) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Sku": "Sku", "Amount": "Amount"}}, - ) - sql = translation.evaluates[0].sql - assert "STRING_AGG(CAST(Sku AS VARCHAR), CAST(',' AS VARCHAR) ORDER BY Amount DESC)" in sql - assert "FROM Sales" in sql - assert "AS SkuList" in sql - - -def test_translate_query_summarizecolumns_median_and_medianx_expressions(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - "MedianAmt", MEDIAN('Sales'[Amount]), - "MedianAmtX", MEDIANX('Sales', 'Sales'[Amount]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}}, - ) - sql = translation.evaluates[0].sql - assert "MEDIAN(Sales.Amount) AS MedianAmt" in sql - assert "MEDIAN(Sales.Amount) AS MedianAmtX" in sql - - -def test_translate_query_define_measure_with_totalytd_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[SalesYTD] = TOTALYTD(SUM('Sales'[Amount]), 'Date'[Date]) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "SalesYTD", [SalesYTD] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "Date": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"SalesYTD"}}, - time_dimensions_by_table={"Date": {"Date"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - sql = translation.evaluates[0].sql - assert "OVER (" in sql - assert "AS SalesYTD" in sql - - -def test_translate_query_define_measure_with_sameperiodlastyear_expression(): - query = _parse_query( - """ - DEFINE - MEASURE 'Sales'[PrevSales] = CALCULATE(SUM('Sales'[Amount]), SAMEPERIODLASTYEAR('Date'[Date])) - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "PrevSales", [PrevSales] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "Date": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"PrevSales"}}, - time_dimensions_by_table={"Date": {"Date"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - sql = translation.evaluates[0].sql - assert "LAG(" in sql - assert "AS PrevSales" in sql - - -def test_translate_query_sameperiodlastyear_model_measure_reference_uses_measure_sql(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "PrevSales", - CALCULATE([Total Sales], SAMEPERIODLASTYEAR('Date'[Date])) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "Date": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"Total Sales"}}, - measure_aggs_by_table={"Sales": {"Total Sales": "sum"}}, - measure_sql_by_table={"Sales": {"Total Sales": "Amount"}}, - time_dimensions_by_table={"Date": {"Date"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - sql = translation.evaluates[0].sql - assert "LAG(SUM(Amount), 1) OVER (" in sql - assert "AS PrevSales" in sql - - -def test_translate_query_model_measure_reference_uses_measure_metadata(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - "Revenue", [Revenue] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - measure_names_by_table={"Sales": {"Revenue"}}, - measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, - measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "GROUP BY Sales.Category" in sql - - -def test_translate_query_model_measure_reference_joins_cross_table_grouping(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", [Revenue] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - measure_names_by_table={"Sales": {"Revenue"}}, - measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, - measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey" in sql - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_calculate_model_measure_reference_applies_filters_inside_aggregate(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Category], - "Revenue", CALCULATE([Revenue], 'Sales'[Amount] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - measure_names_by_table={"Sales": {"Revenue"}}, - measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, - measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SUM(CASE WHEN (Sales.Amount > 0) THEN Sales.Amount ELSE NULL END) AS Revenue" in sql - assert "CASE WHEN (Sales.Amount > 0) THEN SUM" not in sql - - -def test_translate_query_row_model_measure_reference_adds_measure_table_source(): - query = _parse_query('EVALUATE ROW("Revenue", [Revenue])') - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category"}}, - measure_names_by_table={"Sales": {"Revenue"}}, - measure_aggs_by_table={"Sales": {"Revenue": "sum"}}, - measure_sql_by_table={"Sales": {"Revenue": "Amount"}}, - ) - - assert translation.evaluates[0].sql == "SELECT SUM(Sales.Amount) AS Revenue FROM Sales" - - -def test_translate_query_define_var_is_resolved(): - query = _parse_query( - """ - DEFINE - VAR threshold = 10 - EVALUATE - FILTER('Sales', 'Sales'[Amount] > threshold) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (Amount > 10)" in sql - - -def test_translate_query_define_function_is_inlined(): - query = _parse_query( - """ - DEFINE - FUNCTION add_tax = (x : NUMERIC) => x * 1.1 - EVALUATE - SELECTCOLUMNS( - 'Sales', - "Gross", - add_tax('Sales'[Amount]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "(Amount * 1.1) AS Gross" in sql - - -def test_translate_query_define_column_is_resolved(): - query = _parse_query( - """ - DEFINE - COLUMN 'Sales'[Net] = 'Sales'[Amount] - 1 - EVALUATE - SELECTCOLUMNS( - 'Sales', - "Net", - 'Sales'[Net] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Net": "Net"}}, - ) - - sql = translation.evaluates[0].sql - assert "(Amount - 1) AS Net" in sql - - -def test_translate_query_table_constructor_evaluate(): - query = _parse_query( - """ - EVALUATE - { 1, 2 } - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "SELECT 1 AS value1" in sql - assert "SELECT 2 AS value1" in sql - - -def test_translate_query_table_constructor_evaluate_with_table_column_ref_adds_from_clause(): - query = _parse_query( - """ - EVALUATE - { ('Sales'[Amount], 2) } - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Amount AS value1, 2 AS value2 FROM Sales" in sql - assert "Sales" in translation.evaluates[0].required_models - - -def test_translate_query_table_constructor_evaluate_allows_multi_table_refs(): - query = _parse_query( - """ - EVALUATE - { ('Sales'[Amount], 'Date'[DateKey]) } - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Amount AS value1, Date.DateKey AS value2 FROM Sales CROSS JOIN Date" in sql - assert "Sales" in translation.evaluates[0].required_models - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_row_evaluate(): - query = _parse_query( - """ - EVALUATE - ROW("one", 1, "two", 2) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "SELECT 1 AS one, 2 AS two" in sql - - -def test_translate_query_row_evaluate_allows_multi_table_refs(): - query = _parse_query( - """ - EVALUATE - ROW("sales_amount", 'Sales'[Amount], "date_key", 'Date'[DateKey]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Amount AS sales_amount, Date.DateKey AS date_key FROM Sales CROSS JOIN Date" in sql - assert "Sales" in translation.evaluates[0].required_models - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_row_evaluate_numeric_scalar_functions(): - query = _parse_query( - """ - EVALUATE - ROW( - "abs_value", ABS(-5), - "mod_value", MOD(10, 3), - "pow_value", POWER(2, 3), - "sqrt_value", SQRT(9), - "log_value", LOG(100), - "pi_value", PI(), - "min_value", MIN(2, 3), - "max_value", MAX(2, 3) - ) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "ABS(-5) AS abs_value" in sql - assert "MOD(10, 3) AS mod_value" in sql - assert "POWER(2, 3) AS pow_value" in sql - assert "SQRT(9) AS sqrt_value" in sql - assert "LOG10(100) AS log_value" in sql - assert "PI() AS pi_value" in sql - assert "LEAST(2, 3) AS min_value" in sql - assert "GREATEST(2, 3) AS max_value" in sql - - -def test_translate_query_row_requires_name_expression_pairs(): - query = _parse_query( - """ - EVALUATE - ROW("one", 1, "bad") - """ - ) - - with pytest.raises(DaxTranslationError, match="ROW requires name/expression pairs"): - translate_dax_query(query) - - -def test_translate_query_union_evaluate(): - query = _parse_query( - """ - EVALUATE - UNION({1}, {2}) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "UNION ALL" in sql - assert "SELECT * FROM (SELECT 1 AS value1) AS t0" in sql - assert "SELECT * FROM (SELECT 2 AS value1) AS t1" in sql - - -def test_translate_query_union_allows_multi_table_refs(): - query = _parse_query( - """ - EVALUATE - UNION('sales', 'tax') - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "tax": {"rate": "rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "UNION ALL" in sql - assert "SELECT * FROM (SELECT * FROM sales) AS t0" in sql - assert "SELECT * FROM (SELECT * FROM tax) AS t1" in sql - - -def test_translate_query_union_requires_two_tables(): - query = _parse_query( - """ - EVALUATE - UNION({1}) - """ - ) - - with pytest.raises(DaxTranslationError, match="UNION requires at least two table arguments"): - translate_dax_query(query) - - -def test_translate_query_intersect_evaluate(): - query = _parse_query( - """ - EVALUATE - INTERSECT({1, 2}, {2, 3}) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "INTERSECT ALL" in sql - assert "SELECT * FROM (SELECT 1 AS value1 UNION ALL SELECT 2 AS value1) AS t0" in sql - assert "SELECT * FROM (SELECT 2 AS value1 UNION ALL SELECT 3 AS value1) AS t1" in sql - - -def test_translate_query_except_evaluate(): - query = _parse_query( - """ - EVALUATE - EXCEPT({1, 2}, {2, 3}) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "EXCEPT ALL" in sql - assert "SELECT * FROM (SELECT 1 AS value1 UNION ALL SELECT 2 AS value1) AS t0" in sql - assert "SELECT * FROM (SELECT 2 AS value1 UNION ALL SELECT 3 AS value1) AS t1" in sql - - -def test_translate_query_intersect_requires_two_tables(): - query = _parse_query( - """ - EVALUATE - INTERSECT({1}) - """ - ) - - with pytest.raises(DaxTranslationError, match="INTERSECT requires exactly two table arguments"): - translate_dax_query(query) - - -def test_translate_query_except_requires_two_tables(): - query = _parse_query( - """ - EVALUATE - EXCEPT({1}) - """ - ) - - with pytest.raises(DaxTranslationError, match="EXCEPT requires exactly two table arguments"): - translate_dax_query(query) - - -def test_translate_query_crossjoin_evaluate(): - query = _parse_query( - """ - EVALUATE - CROSSJOIN({1}, {2}) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM (SELECT 1 AS value1) AS t0 CROSS JOIN (SELECT 2 AS value1) AS t1" in sql - - -def test_translate_query_crossjoin_requires_two_tables(): - query = _parse_query( - """ - EVALUATE - CROSSJOIN({1}) - """ - ) - - with pytest.raises(DaxTranslationError, match="CROSSJOIN requires at least two table arguments"): - translate_dax_query(query) - - -def test_translate_query_naturalinnerjoin_evaluate(): - query = _parse_query( - """ - EVALUATE - NATURALINNERJOIN(ROW("k", 1), ROW("k", 1)) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "NATURAL INNER JOIN" in sql - assert "SELECT * FROM (SELECT 1 AS k) AS t0 NATURAL INNER JOIN (SELECT 1 AS k) AS t1" in sql - - -def test_translate_query_naturalinnerjoin_requires_two_tables(): - query = _parse_query( - """ - EVALUATE - NATURALINNERJOIN(ROW("k", 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="NATURALINNERJOIN requires exactly two table arguments"): - translate_dax_query(query) - - -def test_translate_query_naturalleftouterjoin_evaluate(): - query = _parse_query( - """ - EVALUATE - NATURALLEFTOUTERJOIN(ROW("k", 1), ROW("k", 2)) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "NATURAL LEFT JOIN" in sql - assert "SELECT * FROM (SELECT 1 AS k) AS t0 NATURAL LEFT JOIN (SELECT 2 AS k) AS t1" in sql - - -def test_translate_query_naturalleftouterjoin_requires_two_tables(): - query = _parse_query( - """ - EVALUATE - NATURALLEFTOUTERJOIN(ROW("k", 1)) - """ - ) - - with pytest.raises(DaxTranslationError, match="NATURALLEFTOUTERJOIN requires exactly two table arguments"): - translate_dax_query(query) - - -def test_translate_query_generate_table(): - query = _parse_query( - """ - EVALUATE - GENERATE( - VALUES('Date'[Fiscal Year]), - VALUES('Product'[Category]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Product": {"Category": "Category"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "SELECT DISTINCT Date.FiscalYear FROM Date" in sql - assert "SELECT DISTINCT Product.Category FROM Product" in sql - - -def test_translate_query_generate_allows_cross_table_right_input(): - query = _parse_query( - """ - EVALUATE - GENERATE( - 'sales', - FILTER('tax', 'tax'[rate] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "tax": {"rate": "rate", "date_key": "date_key"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "FROM (SELECT * FROM sales) AS l" in sql - assert "FROM tax" in sql - assert "rate > 0" in sql - - -def test_translate_query_generateall_table(): - query = _parse_query( - """ - EVALUATE - GENERATEALL( - VALUES('Date'[Fiscal Year]), - VALUES('Product'[Category]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Product": {"Category": "Category"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "LEFT JOIN LATERAL" in sql - assert "ON TRUE" in sql - - -def test_translate_query_generateall_allows_cross_table_right_input(): - query = _parse_query( - """ - EVALUATE - GENERATEALL( - 'sales', - FILTER('tax', 'tax'[rate] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "tax": {"rate": "rate", "date_key": "date_key"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "LEFT JOIN LATERAL" in sql - assert "ON TRUE" in sql - assert "FROM (SELECT * FROM sales) AS l" in sql - assert "FROM tax" in sql - assert "rate > 0" in sql - - -def test_translate_query_generate_uses_lateral_join_shape(): - query = _parse_query( - """ - EVALUATE - GENERATE( - VALUES('Date'[Fiscal Year]), - FILTER('Date', 'Date'[Fiscal Year] > 2022) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "WHERE (FiscalYear > 2022)" in sql - - -def test_translate_query_generate_correlates_left_alias_column_in_right_filter(): - query = _parse_query( - """ - EVALUATE - GENERATE( - SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), - FILTER('Date', [FY] >= 2024) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "SELECT FiscalYear AS FY FROM Date" in sql - assert "WHERE (l.FY >= 2024)" in sql - - -def test_translate_query_generate_correlates_qualified_left_column_lineage_to_alias(): - query = _parse_query( - """ - EVALUATE - GENERATE( - SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), - FILTER('Date', 'Date'[Fiscal Year] >= 2024) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "SELECT FiscalYear AS FY FROM Date" in sql - assert "WHERE (FiscalYear >= 2024)" in sql - - -def test_translate_query_generate_does_not_correlate_non_projected_left_column(): - query = _parse_query( - """ - EVALUATE - GENERATE( - VALUES('Date'[Fiscal Year]), - VALUES('Date'[DateKey]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "SELECT DISTINCT Date.FiscalYear FROM Date" in sql - assert "SELECT DISTINCT Date.DateKey FROM Date" in sql - assert "l.DateKey" not in sql - - -def test_translate_query_generate_nested_filter_preserves_local_right_row_context(): - query = _parse_query( - """ - EVALUATE - GENERATE( - SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), - SUMMARIZE( - FILTER('Date', 'Date'[Fiscal Year] > [FY]), - 'Date'[DateKey] - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "WHERE (FiscalYear > l.FY)" in sql - assert "l.FY > l.FY" not in sql - - -def test_translate_query_generate_nested_filter_with_addcolumns_alias_keeps_right_row_context(): - query = _parse_query( - """ - EVALUATE - GENERATE( - ADDCOLUMNS(VALUES('Date'[Fiscal Year]), "FY2", 'Date'[Fiscal Year]), - FILTER('Date', 'Date'[Fiscal Year] = [FY2]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "WHERE (FiscalYear = l.FY2)" in sql - assert "l.FY2 = l.FY2" not in sql - - -def test_translate_query_generate_does_not_correlate_local_wrapped_alias_in_right_filter(): - query = _parse_query( - """ - EVALUATE - GENERATE( - SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), - FILTER( - SELECTCOLUMNS('Date', "FY", 'Date'[Fiscal Year]), - [FY] >= 2024 - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "WHERE (FY >= 2024)" in sql - assert "WHERE (l.FY >= 2024)" not in sql - - -def test_translate_query_generate_correlates_outer_column_when_left_uses_star_projection(): - query = _parse_query( - """ - EVALUATE - GENERATE( - 'Date', - ROW("OuterDateKey", [DateKey]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"DateKey": "DateKey", "Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "SELECT l.DateKey AS OuterDateKey" in sql - - -def test_translate_query_generate_left_star_does_not_rewrite_local_wrapped_right_column(): - query = _parse_query( - """ - EVALUATE - GENERATE( - 'Date', - FILTER( - SELECTCOLUMNS('Date', "DateKey", 'Date'[DateKey]), - [DateKey] >= 2024 - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"DateKey": "DateKey", "Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "CROSS JOIN LATERAL" in sql - assert "WHERE (DateKey >= 2024)" in sql - assert "WHERE (l.DateKey >= 2024)" not in sql - - -def test_translate_query_generateall_correlates_outer_column_when_left_uses_star_projection(): - query = _parse_query( - """ - EVALUATE - GENERATEALL( - 'Date', - ROW("OuterDateKey", [DateKey]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"DateKey": "DateKey", "Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert "LEFT JOIN LATERAL" in sql - assert "ON TRUE" in sql - assert "SELECT l.DateKey AS OuterDateKey" in sql - - -def test_translate_query_generate_raises_for_ambiguous_multitable_star_outer_column_reference(): - query = _parse_query( - """ - EVALUATE - GENERATE( - CROSSJOIN( - VALUES('Date'[DateKey]), - VALUES('Sales'[DateKey]) - ), - ROW("OuterDateKey", [DateKey]) - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="Ambiguous outer column reference"): - translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey"}, - "Sales": {"DateKey": "DateKey"}, - }, - ) - - -def test_translate_query_generateall_raises_for_ambiguous_multitable_star_outer_column_reference(): - query = _parse_query( - """ - EVALUATE - GENERATEALL( - CROSSJOIN( - VALUES('Date'[DateKey]), - VALUES('Sales'[DateKey]) - ), - ROW("OuterDateKey", [DateKey]) - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="Ambiguous outer column reference"): - translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey"}, - "Sales": {"DateKey": "DateKey"}, - }, - ) - - -@pytest.mark.parametrize("fn_name", ["GENERATE", "GENERATEALL"]) -def test_translate_query_generate_raises_for_ambiguous_duplicate_lineage_outer_column_reference(fn_name: str): - query = _parse_query( - f""" - EVALUATE - {fn_name}( - SELECTCOLUMNS( - 'Date', - "DateKey1", 'Date'[DateKey], - "DateKey2", 'Date'[DateKey] - ), - ROW("OuterDateKey", 'Date'[DateKey]) - ) - """ - ) - - with pytest.raises(DaxTranslationError, match="Ambiguous outer column reference"): - translate_dax_query( - query, - column_sql_by_table={ - "Date": {"DateKey": "DateKey"}, - }, - ) - - -def test_rewrite_expr_for_alias_keeps_nested_qualified_reference_local_to_enclosing_scope(): - sql = "SELECT * FROM Date AS Date WHERE EXISTS (SELECT 1 FROM Sales WHERE Sales.DateKey = Date.DateKey)" - rewritten = _rewrite_expr_for_alias(sql, "l", source_tables={"Date"}, source_columns={"DateKey"}) - - assert "Sales.DateKey = Date.DateKey" in rewritten - assert "Sales.DateKey = l.DateKey" not in rewritten - - -def test_rewrite_expr_for_alias_rewrites_when_no_enclosing_local_table_scope_exists(): - sql = "SELECT * FROM Sales WHERE Sales.DateKey = Date.DateKey" - rewritten = _rewrite_expr_for_alias(sql, "l", source_tables={"Date"}, source_columns={"DateKey"}) - - assert "Sales.DateKey = l.DateKey" in rewritten - - -def test_rewrite_expr_for_alias_skips_ambiguous_multitable_wildcard_rewrite(): - sql = "SELECT * FROM Sales WHERE Date.DateKey = Sales.DateKey" - rewritten = _rewrite_expr_for_alias( - sql, - "l", - source_tables={"Date", "Product"}, - source_columns={"*"}, - ambiguous_source_columns={"DateKey"}, - ) - - assert "Date.DateKey = Sales.DateKey" in rewritten - assert "l.DateKey = Sales.DateKey" not in rewritten - - -def test_rewrite_expr_for_alias_raises_when_strict_rewrite_cannot_parse(monkeypatch): - import sqlglot - - def _boom(*_args, **_kwargs): - raise ValueError("parse failed") - - monkeypatch.setattr(sqlglot, "parse_one", _boom) - - with pytest.raises(DaxTranslationError, match="Unable to safely correlate outer column references"): - _rewrite_expr_for_alias( - "SELECT Date.DateKey", - "l", - source_tables={"Date"}, - source_columns={"DateKey"}, - allow_fallback=False, - strict_source_resolution=True, - ) - - -def test_translate_query_topnskip(): - query = _parse_query( - """ - EVALUATE - TOPNSKIP(2, 1, 'Sales', 'Sales'[Amount], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "ORDER BY" in sql - assert "DESC" in sql - assert "LIMIT 2 OFFSET 1" in sql - - -def test_translate_query_topn_accepts_scalar_count_expression(): - query = _parse_query( - """ - EVALUATE - TOPN(1 + 1, 'Sales', 'Sales'[Amount], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "ORDER BY" in sql - assert "DESC" in sql - assert "LIMIT CAST(((1 + 1)) AS BIGINT)" in sql - - -def test_translate_query_topnskip_accepts_scalar_skip_expression(): - query = _parse_query( - """ - EVALUATE - TOPNSKIP(3 - 1, 5 / 2, 'Sales', 'Sales'[Amount], ASC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "ORDER BY" in sql - assert "ASC" in sql - assert "LIMIT CAST(((3 - 1)) AS BIGINT)" in sql - assert "OFFSET CAST(((5 / 2)) AS BIGINT)" in sql - - -def test_translate_query_topn_cross_table_order_by_joins_related_table(): - query = _parse_query( - """ - EVALUATE - TOPN(2, 'Sales', 'Product'[Category], ASC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql - assert "ORDER BY Product.Category ASC LIMIT 2" in sql - - -def test_translate_query_topnskip_cross_table_order_by_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - TOPNSKIP(2, 1, 'Sales', 'Tax'[Rate], ASC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales CROSS JOIN Tax ORDER BY Tax.Rate ASC LIMIT 2 OFFSET 1" in sql - - -def test_translate_query_topn_wrapped_base_cross_table_order_by_joins_related_table(): - query = _parse_query( - """ - EVALUATE - TOPN(2, FILTER('Sales', 'Sales'[Amount] > 0), 'Product'[Category], ASC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert ( - "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" - in sql - ) - assert "ORDER BY Product.Category ASC LIMIT 2" in sql - - -def test_translate_query_topnskip_wrapped_base_cross_table_order_by_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - TOPNSKIP(2, 1, FILTER('Sales', 'Sales'[Amount] > 0), 'Tax'[Rate], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert ( - "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t CROSS JOIN Tax ORDER BY Tax.Rate DESC LIMIT 2 OFFSET 1" - in sql - ) - - -def test_translate_query_addmissingitems_with_summarizecolumns(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "ADDMISSINGITEMS" not in sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql - assert "LEFT JOIN (" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "b.* EXCLUDE (FiscalYear)" in sql - assert "AS Revenue" in sql - - -def test_translate_query_addmissingitems_with_multiple_group_columns(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - 'Product'[Category], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - 'Product'[Category], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Product": {"Category": "Category"}, - "Sales": {"Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0, Product.Category AS __addmissingitems_k1" in sql - assert "LEFT JOIN (" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "b.Category IS NOT DISTINCT FROM d.__addmissingitems_k1" in sql - - -def test_translate_query_addmissingitems_showall_column_not_projected_by_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - 'Product'[Category], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Product": {"Category": "Category", "ProductKey": "ProductKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey", "ProductKey": "ProductKey"}, - }, - relationship_edges=[ - RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), - RelationshipEdge("Sales", "ProductKey", "Product", "ProductKey"), - ], - ) - - sql = translation.evaluates[0].sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "b.Category IS NOT DISTINCT FROM d.__addmissingitems_k1" not in sql - assert "AS Revenue" in sql - - -def test_translate_query_addmissingitems_showall_only_with_scalar_table_arg_uses_true_join(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Product'[Category], - ROW("Revenue", 1) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Product": {"Category": "Category"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "ON TRUE" in sql - assert "SELECT DISTINCT Product.Category AS __addmissingitems_k0 FROM Product" in sql - - -def test_translate_query_addmissingitems_applies_trailing_filter_table_to_domain(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - FILTER('Date', 'Date'[Fiscal Year] >= 2024) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "LEFT JOIN (" in sql - - -def test_translate_query_addmissingitems_filter_table_adds_domain_join_tables(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - FILTER('Sales', 'Sales'[Amount] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "FROM Date LEFT JOIN Sales ON Date.DateKey = Sales.DateKey WHERE (Sales.Amount > 0)" in sql - assert "LEFT JOIN (" in sql - - -def test_translate_query_addmissingitems_filter_before_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - FILTER('Date', 'Date'[Fiscal Year] >= 2024), - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "LEFT JOIN (" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - - -def test_translate_query_addmissingitems_direct_group_table_before_main_table(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - 'Date', - 'Sales' - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Sales": {"Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql - assert "LEFT JOIN (SELECT * FROM Sales) AS b ON TRUE" in sql - - -def test_translate_query_addmissingitems_direct_group_table_before_calculatetable_main(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - 'Date', - CALCULATETABLE('Sales', 'Sales'[Amount] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Sales": {"Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql - assert "LEFT JOIN (SELECT * FROM Sales WHERE (Sales.Amount > 0)) AS b ON TRUE" in sql - - -def test_translate_query_addmissingitems_direct_group_table_before_nonvisual_calculatetable_main(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - 'Date', - NONVISUAL(CALCULATETABLE('Sales', 'Sales'[Amount] > 0)) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Sales": {"Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql - assert "LEFT JOIN (SELECT * FROM Sales WHERE (Sales.Amount > 0)) AS b ON TRUE" in sql - - -def test_translate_query_addmissingitems_leading_nonvisual_calculatetable_filter_before_main_table(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - NONVISUAL(CALCULATETABLE('Date', 'Date'[Fiscal Year] >= 2024)), - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "LEFT JOIN (SELECT * FROM Date WHERE (Date.FiscalYear >= 2024)) AS b ON TRUE" not in sql - - -def test_translate_query_addmissingitems_trailing_calculatetable_filter(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - CALCULATETABLE('Date', 'Date'[Fiscal Year] >= 2024) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "LEFT JOIN (" in sql - - -def test_translate_query_addmissingitems_prefers_wrapped_summarizecolumns_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - FILTER('Date', 'Date'[Fiscal Year] >= 2023), - CALCULATETABLE( - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - 'Date'[Fiscal Year] >= 2024 - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2023)" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "WHERE (FiscalYear >= 2024)" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert " ON TRUE" not in sql - - -def test_translate_query_addmissingitems_prefers_keepfilters_wrapped_summarize_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - FILTER('Date', 'Date'[Fiscal Year] >= 2024), - KEEPFILTERS( - SUMMARIZE( - 'Sales', - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "LEFT JOIN (SELECT * FROM Date WHERE (FiscalYear >= 2024)) AS b ON TRUE" not in sql - - -def test_translate_query_addmissingitems_prefers_calculatetable_wrapped_summarize_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - FILTER('Date', 'Date'[Fiscal Year] >= 2024), - CALCULATETABLE( - SUMMARIZE( - 'Sales', - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - 'Date'[Fiscal Year] >= 2023 - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "WHERE (Date.FiscalYear >= 2023)" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "LEFT JOIN (SELECT * FROM Date WHERE (FiscalYear >= 2024)) AS b ON TRUE" not in sql - - -def test_translate_query_addmissingitems_prefers_nonvisual_keepfilters_wrapped_summarize_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - FILTER('Date', 'Date'[Fiscal Year] >= 2024), - NONVISUAL( - KEEPFILTERS( - SUMMARIZE( - 'Sales', - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "LEFT JOIN (SELECT * FROM Date WHERE (FiscalYear >= 2024)) AS b ON TRUE" not in sql - - -def test_translate_query_addmissingitems_keeps_first_non_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "LEFT JOIN (" in sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql - - -def test_translate_query_addmissingitems_union_before_main_table_arg(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])), - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "LEFT JOIN (" in sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql - - -def test_translate_query_addmissingitems_union_main_table_with_trailing_filter(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])), - FILTER('Date', 'Date'[Fiscal Year] >= 2024) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Date": {"Fiscal Year": "FiscalYear"}}, - ) - - sql = translation.evaluates[0].sql - assert ( - "LEFT JOIN (SELECT * FROM (SELECT DISTINCT Date.FiscalYear FROM Date) AS t0 UNION ALL SELECT * FROM (SELECT DISTINCT Date.FiscalYear FROM Date) AS t1) AS b" - in sql - ) - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - - -def test_translate_query_addmissingitems_prefers_non_group_calculatetable_over_leading_group_union(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year])), - CALCULATETABLE('Sales', 'Sales'[Amount] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Sales": {"Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date" in sql - assert "FROM Date CROSS JOIN Sales WHERE (Sales.Amount > 0)" not in sql - assert "LEFT JOIN (SELECT * FROM Sales WHERE (Sales.Amount > 0)) AS b ON TRUE" in sql - - -def test_translate_query_addmissingitems_prefers_summarize_over_leading_union_candidate(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - UNION(VALUES('Sales'[DateKey]), VALUES('Sales'[DateKey])), - SUMMARIZE( - 'Sales', - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "LEFT JOIN (SELECT * FROM (SELECT DISTINCT Sales.DateKey FROM Sales) AS t0 UNION ALL" not in sql - - -def test_translate_query_addmissingitems_prefers_wrapped_summarize_over_leading_union_candidate(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - UNION(VALUES('Sales'[DateKey]), VALUES('Sales'[DateKey])), - CALCULATETABLE( - SUMMARIZE( - 'Sales', - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - 'Date'[Fiscal Year] >= 2024 - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"DateKey": "DateKey", "Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "WHERE (Date.FiscalYear >= 2024)" in sql - assert "LEFT JOIN (SELECT * FROM (SELECT DISTINCT Sales.DateKey FROM Sales) AS t0 UNION ALL" not in sql - - -@pytest.mark.parametrize( - "leading_set_expr", - [ - "UNION(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", - "INTERSECT(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", - "EXCEPT(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", - "CROSSJOIN(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", - "NATURALINNERJOIN(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", - "NATURALLEFTOUTERJOIN(VALUES('Date'[Fiscal Year]), VALUES('Date'[Fiscal Year]))", - ], -) -def test_translate_query_addmissingitems_prefers_summarize_over_leading_set_or_join_candidate(leading_set_expr: str): - query = _parse_query( - f""" - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - {leading_set_expr}, - SUMMARIZE( - 'Sales', - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear"}, - "Sales": {"Amount": "Amount"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - - -def test_translate_query_addmissingitems_supports_trailing_group_by_column(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - 'Product'[Category], - "Revenue", SUM('Sales'[Amount]) - ), - 'Product'[Category] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Product": {"Category": "Category", "ProductKey": "ProductKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey", "ProductKey": "ProductKey"}, - }, - relationship_edges=[ - RelationshipEdge("Sales", "DateKey", "Date", "DateKey"), - RelationshipEdge("Sales", "ProductKey", "Product", "ProductKey"), - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0, Product.Category AS __addmissingitems_k1" in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - assert "b.Category IS NOT DISTINCT FROM d.__addmissingitems_k1" in sql - - -def test_translate_query_addmissingitems_deduplicates_repeated_group_column(): - query = _parse_query( - """ - EVALUATE - ADDMISSINGITEMS( - 'Date'[Fiscal Year], - SUMMARIZECOLUMNS( - 'Date'[Fiscal Year], - "Revenue", SUM('Sales'[Amount]) - ), - FILTER('Date', 'Date'[Fiscal Year] >= 2024), - 'Date'[Fiscal Year] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Date": {"Fiscal Year": "FiscalYear", "DateKey": "DateKey"}, - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - }, - relationship_edges=[RelationshipEdge("Sales", "DateKey", "Date", "DateKey")], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Date.FiscalYear AS __addmissingitems_k0 FROM Date WHERE (Date.FiscalYear >= 2024)" in sql - assert "__addmissingitems_k1" not in sql - assert "b.FiscalYear IS NOT DISTINCT FROM d.__addmissingitems_k0" in sql - - -def test_translate_query_groupby_with_currentgroup_iterators(): - query = _parse_query( - """ - EVALUATE - GROUPBY( - 'Sales', - 'Sales'[Category], - "Revenue", SUMX(CURRENTGROUP(), 'Sales'[Amount]), - "Rows", COUNTROWS(CURRENTGROUP()) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "GROUP BY" in sql - assert "SUM(Sales.Amount) AS Revenue" in sql - assert "COUNT(*) AS Rows" in sql - - -def test_translate_query_datatable(): - query = _parse_query( - """ - EVALUATE - DATATABLE( - "k", INTEGER, - "v", STRING, - {{1, "a"}, {2, "b"}} - ) - """ - ) - - translation = translate_dax_query(query) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM (VALUES (1, 'a'), (2, 'b')) AS t(k, v)" in sql - - -def test_translate_query_topnperlevel(): - query = _parse_query( - """ - EVALUATE - TOPNPERLEVEL(2, 'Sales'[Category], 'Sales', 'Sales'[Amount], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "RANK() OVER (PARTITION BY Sales.Category ORDER BY Amount DESC)" in sql - assert "__topnperlevel_rank <= 2" in sql - - -def test_translate_query_topnperlevel_multiple_group_columns(): - query = _parse_query( - """ - EVALUATE - TOPNPERLEVEL(1, 'Sales'[Category], 'Sales'[Region], 'Sales', 'Sales'[Amount], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Category": "Category", "Region": "Region", "Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "PARTITION BY Sales.Category, Sales.Region" in sql - assert "ORDER BY Amount DESC" in sql - - -def test_translate_query_topnperlevel_cross_table_group_order_joins_related_table(): - query = _parse_query( - """ - EVALUATE - TOPNPERLEVEL(1, 'Product'[Category], 'Sales', 'Product'[Category], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.*, RANK() OVER (PARTITION BY Product.Category ORDER BY Product.Category DESC)" in sql - assert "FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql - - -def test_translate_query_topnperlevel_wrapped_base_cross_table_group_order_joins_related_table(): - query = _parse_query( - """ - EVALUATE - TOPNPERLEVEL(1, 'Product'[Category], FILTER('Sales', 'Sales'[Amount] > 0), 'Product'[Category], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT t.*, RANK() OVER (PARTITION BY Product.Category ORDER BY Product.Category DESC)" in sql - assert ( - "FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" - in sql - ) - - -def test_translate_query_topnperlevel_wrapped_base_cross_table_group_order_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - TOPNPERLEVEL(1, 'Tax'[Region], FILTER('Sales', 'Sales'[Amount] > 0), 'Tax'[Rate], DESC) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Region": "Region", "Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT t.*, RANK() OVER (PARTITION BY Tax.Region ORDER BY Tax.Rate DESC)" in sql - assert "FROM (SELECT * FROM Sales WHERE (Amount > 0)) AS t CROSS JOIN Tax" in sql - - -def test_translate_query_define_table_is_resolved(): - query = _parse_query( - """ - DEFINE - TABLE MyTable = FILTER('Sales', 'Sales'[Amount] > 100) - EVALUATE - MyTable - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Amount > 100)" in sql - - -def test_translate_query_evaluate_filter_with_cross_table_predicate_joins_related_table(): - query = _parse_query( - """ - EVALUATE - FILTER('Sales', 'Product'[Category] = "Clothing") - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql - assert "WHERE (Product.Category = 'Clothing')" in sql - - -def test_translate_query_evaluate_filter_with_cross_table_predicate_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - FILTER('Sales', 'Tax'[Rate] > 0) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql - - -def test_translate_query_summarizecolumns_filter_derived_table_alias_predicate_uses_exists_subquery(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Amount], - FILTER({ ('Sales'[Amount], 'Date'[DateKey]) }, [value1] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "WHERE EXISTS (SELECT 1 FROM (" in sql - assert "SELECT * FROM (SELECT" in sql - assert "AS value1" in sql - assert "AS value2 FROM Sales CROSS JOIN Date) AS t WHERE (value1 > 0)" in sql - assert "Sales.value1" not in sql - assert "FROM Sales CROSS JOIN Date WHERE EXISTS" not in sql - assert "FROM Sales WHERE EXISTS" in sql - - -def test_translate_query_summarizecolumns_filter_values_known_column_stays_direct_predicate(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[ProductKey], - FILTER(VALUES('Sales'[ProductKey]), 'Sales'[ProductKey] > 1) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"ProductKey": "ProductKey"}}, - ) - - sql = translation.evaluates[0].sql - assert "ProductKey > 1" in sql - assert "EXISTS (SELECT 1 FROM (" not in sql - - -def test_translate_query_summarizecolumns_filter_selectcolumns_alias_predicate_rewrites_directly(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Sales'[Amount], - FILTER(SELECTCOLUMNS('Sales', "x", 'Sales'[Amount]), [x] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "WHERE (Sales.Amount > 0)" in sql - assert "EXISTS (SELECT 1 FROM (" not in sql - - -def test_translate_query_evaluate_calculatetable_base_table_selectcolumns_alias_filter_rewrites_directly(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - 'Sales', - FILTER(SELECTCOLUMNS('Sales', "x", 'Sales'[Amount]), [x] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Sales.Amount > 0)" in sql - assert "EXISTS (SELECT 1 FROM (" not in sql - - -def test_translate_query_evaluate_calculatetable(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE('Sales', 'Sales'[Amount] > 100) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql - - -def test_translate_query_evaluate_calculatetable_base_table_cross_table_filter_joins_related_table(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE('Sales', 'Product'[Category] = "Clothing") - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales LEFT JOIN Product ON Sales.ProductKey = Product.ProductKey" in sql - assert "WHERE (Product.Category = 'Clothing')" in sql - - -def test_translate_query_evaluate_calculatetable_base_table_cross_table_table_ref_filter_candidate(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE('Sales', 'Date') - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales" in sql - assert "Date" in translation.evaluates[0].required_models - - -def test_translate_query_evaluate_calculatetable_base_table_cross_table_filter_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE('Sales', 'Tax'[Rate] > 0) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql - - -def test_translate_query_evaluate_calculatetable_base_table_cross_table_datesinperiod_filter_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE('Sales', DATESINPERIOD('Date'[DateKey], "2024-12-31", -3, MONTH)) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales CROSS JOIN Date" in sql - assert "WHERE (Date.DateKey > ('2024-12-31' + INTERVAL '-3 month') AND Date.DateKey <= '2024-12-31')" in sql - - -def test_translate_query_evaluate_calculatetable_base_table_cross_table_datesytd_filter_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE('Sales', DATESYTD('Date'[DateKey])) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales CROSS JOIN Date" in sql - assert ( - "WHERE (Date.DateKey >= DATE_TRUNC('year', (SELECT MAX(Date.DateKey) FROM Date)) " - "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" - ) in sql - - -def test_translate_query_evaluate_calculatetable_base_table_cross_table_dateadd_filter_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE('Sales', DATEADD('Date'[DateKey], -1, YEAR)) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Sales.* FROM Sales CROSS JOIN Date" in sql - assert ( - "WHERE (Date.DateKey > ((SELECT MAX(Date.DateKey) FROM Date) + INTERVAL '-1 year') " - "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" - ) in sql - - -def test_translate_query_evaluate_calculatetable_base_table_derived_alias_filter_does_not_expand_outer_from(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - 'Sales', - FILTER({ ('Sales'[Amount], 'Date'[DateKey]) }, [value1] > 0) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Date": {"DateKey": "DateKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE EXISTS" in sql - assert "FROM Sales CROSS JOIN Date WHERE EXISTS" not in sql - assert "AS value2 FROM Sales CROSS JOIN Date) AS t WHERE (value1 > 0)" in sql - - -def test_translate_query_evaluate_summarizecolumns_cross_table_dateadd_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - DATEADD('Date'[DateKey], -1, YEAR), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Date.FiscalYear, (SELECT COUNT(*) FROM Sales) AS Rows FROM Date" in sql - assert ( - "WHERE (Date.DateKey > ((SELECT MAX(Date.DateKey) FROM Date) + INTERVAL '-1 year') " - "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" - ) in sql - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_evaluate_summarizecolumns_cross_table_datesytd_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - DATESYTD('Date'[DateKey]), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Date.FiscalYear, (SELECT COUNT(*) FROM Sales) AS Rows FROM Date" in sql - assert ( - "WHERE (Date.DateKey >= DATE_TRUNC('year', (SELECT MAX(Date.DateKey) FROM Date)) " - "AND Date.DateKey <= (SELECT MAX(Date.DateKey) FROM Date))" - ) in sql - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_evaluate_summarizecolumns_cross_table_datesinperiod_filter_table_arg(): - query = _parse_query( - """ - EVALUATE - SUMMARIZECOLUMNS( - 'Date'[FiscalYear], - DATESINPERIOD('Date'[DateKey], "2024-12-31", -3, MONTH), - "Rows", COUNTROWS('Sales') - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"DateKey": "DateKey"}, - "Date": {"DateKey": "DateKey", "FiscalYear": "FiscalYear"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Date.FiscalYear, (SELECT COUNT(*) FROM Sales) AS Rows FROM Date" in sql - assert "WHERE (Date.DateKey > ('2024-12-31' + INTERVAL '-3 month') AND Date.DateKey <= '2024-12-31')" in sql - assert "GROUP BY Date.FiscalYear" in sql - - -def test_translate_query_evaluate_calculatetable_wrapped_base(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount] > 200) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t WHERE (Amount > 200)" in sql - - -def test_translate_query_evaluate_calculatetable_wrapped_base_cross_table_filter_joins_related_table(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - FILTER('Sales', 'Sales'[Amount] > 100), - 'Product'[Category] = "Clothing" - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert ( - "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" - in sql - ) - assert "WHERE (Product.Category = 'Clothing')" in sql - - -def test_translate_query_evaluate_calculatetable_wrapped_base_cross_table_filter_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - FILTER('Sales', 'Sales'[Amount] > 100), - 'Tax'[Rate] > 0 - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql - - -def test_translate_query_evaluate_filter_wrapped_base_cross_table_filter_joins_related_table(): - query = _parse_query( - """ - EVALUATE - FILTER( - FILTER('Sales', 'Sales'[Amount] > 100), - 'Product'[Category] = "Clothing" - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Product": {"ProductKey": "ProductKey", "Category": "Category"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Product", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert ( - "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t LEFT JOIN Product ON t.ProductKey = Product.ProductKey" - in sql - ) - assert "WHERE (Product.Category = 'Clothing')" in sql - - -def test_translate_query_evaluate_filter_wrapped_base_cross_table_filter_cross_join_when_unrelated(): - query = _parse_query( - """ - EVALUATE - FILTER( - FILTER('Sales', 'Sales'[Amount] > 100), - 'Tax'[Rate] > 0 - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT t.* FROM (SELECT * FROM Sales WHERE (Amount > 100)) AS t CROSS JOIN Tax WHERE (Tax.Rate > 0)" in sql - - -def test_translate_query_evaluate_nested_calculatetable_replaces_same_column_filter(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE(CALCULATETABLE('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount] > 200) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Sales.Amount > 200)" in sql - assert "(Sales.Amount > 100)" not in sql - - -def test_translate_query_evaluate_values_column(): - query = _parse_query( - """ - EVALUATE - VALUES('Sales'[Amount]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT Sales.Amount FROM Sales" in sql - - -def test_translate_query_evaluate_values_multitable_scalar(): - query = _parse_query( - """ - EVALUATE - VALUES('Sales'[Amount] + 'Products'[Weight]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"ProductKey": "ProductKey", "Amount": "Amount"}, - "Products": {"ProductKey": "ProductKey", "Weight": "Weight"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Products", - to_column="ProductKey", - ) - ], - ) - - sql = translation.evaluates[0].sql - assert "SELECT DISTINCT (Sales.Amount + Products.Weight)" in sql - assert "LEFT JOIN Products ON Sales.ProductKey = Products.ProductKey" in sql - - -def test_translate_query_evaluate_removecolumns_table(): - query = _parse_query( - """ - EVALUATE - REMOVECOLUMNS('Sales', 'Sales'[Amount]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * EXCLUDE (Amount) FROM (SELECT * FROM Sales) AS t" in sql - - -def test_translate_query_evaluate_keepcolumns_table(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS('Sales', 'Sales'[Amount]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT t.Amount FROM (SELECT * FROM Sales) AS t" in sql - - -def test_translate_query_evaluate_keepcolumns_wrapped_table_with_named_column(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS( - SELECTCOLUMNS('Sales', "Amt", 'Sales'[Amount], "Qty", 'Sales'[Quantity]), - "Qty" - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Amount AS Amt, Quantity AS Qty FROM Sales" in sql - assert "SELECT t.Qty FROM (" in sql - - -def test_translate_query_evaluate_keepcolumns_addcolumns_preserves_base_star_columns(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS( - ADDCOLUMNS('Sales', "W", 1), - 'Sales'[Amount] - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT *, 1 AS W FROM Sales" in sql - assert "SELECT t.Amount FROM (" in sql - - -def test_translate_query_evaluate_calculatetable_keepcolumns_preserves_underlying_filters(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - 'Sales', - KEEPCOLUMNS(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql - - -def test_translate_query_evaluate_keepcolumns_requires_column_arg(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS('Sales') - """ - ) - - with pytest.raises( - DaxTranslationError, match="KEEPCOLUMNS requires a table expression and at least one column argument" - ): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_keepcolumns_rejects_missing_input_column(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS('Sales', 'Products'[Weight]) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="KEEPCOLUMNS column 'Weight' references table 'Products' not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - ) - - -def test_translate_query_evaluate_keepcolumns_rejects_wrong_qualified_table_reference(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS('Sales', 'Products'[Amount]) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="KEEPCOLUMNS column 'Amount' references table 'Products' not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Amount": "Amount", "Weight": "Weight"}, - }, - ) - - -def test_translate_query_evaluate_keepcolumns_rejects_unprojected_related_column_in_addcolumns_input(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS( - ADDCOLUMNS('Sales', "W", 'Products'[Weight]), - 'Products'[Weight] - ) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="KEEPCOLUMNS column 'Weight' is not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Products", - to_column="ProductKey", - ) - ], - ) - - -def test_translate_query_evaluate_keepcolumns_rejects_unprojected_related_column_in_wrapped_addcolumns_input(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS( - ADDCOLUMNS( - FILTER('Sales', 'Sales'[Amount] > 0), - "W", 'Products'[Weight] - ), - 'Products'[Weight] - ) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="KEEPCOLUMNS column 'Weight' is not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - relationship_edges=[ - RelationshipEdge( - from_table="Sales", - from_column="ProductKey", - to_table="Products", - to_column="ProductKey", - ) - ], - ) - - -def test_translate_query_evaluate_keepcolumns_rejects_ambiguous_duplicate_input_column(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS(CROSSJOIN('Sales', 'Products'), "ProductKey") - """ - ) - - with pytest.raises( - DaxTranslationError, - match="KEEPCOLUMNS column 'ProductKey' is ambiguous in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - ) - - -def test_translate_query_evaluate_keepcolumns_accepts_qualified_unique_column_from_multitable_input(): - query = _parse_query( - """ - EVALUATE - KEEPCOLUMNS(CROSSJOIN('Sales', 'Products'), 'Products'[Weight]) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - ) - - sql = translation.evaluates[0].sql - assert "SELECT t.Weight FROM (" in sql - - -def test_translate_query_evaluate_removecolumns_wrapped_table_with_named_column(): - query = _parse_query( - """ - EVALUATE - REMOVECOLUMNS( - SELECTCOLUMNS('Sales', "Amt", 'Sales'[Amount], "Qty", 'Sales'[Quantity]), - "Qty" - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Amount AS Amt, Quantity AS Qty FROM Sales" in sql - assert "SELECT * EXCLUDE (Qty) FROM (" in sql - - -def test_translate_query_evaluate_calculatetable_removecolumns_preserves_underlying_filters(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - 'Sales', - REMOVECOLUMNS(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount]) - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql - - -def test_translate_query_evaluate_removecolumns_requires_column_arg(): - query = _parse_query( - """ - EVALUATE - REMOVECOLUMNS('Sales') - """ - ) - - with pytest.raises( - DaxTranslationError, match="REMOVECOLUMNS requires a table expression and at least one column argument" - ): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_removecolumns_rejects_missing_input_column(): - query = _parse_query( - """ - EVALUATE - REMOVECOLUMNS('Sales', 'Products'[Weight]) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="REMOVECOLUMNS column 'Weight' references table 'Products' not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - ) - - -def test_translate_query_evaluate_removecolumns_rejects_wrong_qualified_table_reference(): - query = _parse_query( - """ - EVALUATE - REMOVECOLUMNS('Sales', 'Products'[Amount]) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="REMOVECOLUMNS column 'Amount' references table 'Products' not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Amount": "Amount", "Weight": "Weight"}, - }, - ) - - -def test_translate_query_evaluate_removecolumns_rejects_ambiguous_duplicate_input_column(): - query = _parse_query( - """ - EVALUATE - REMOVECOLUMNS(CROSSJOIN('Sales', 'Products'), "ProductKey") - """ - ) - - with pytest.raises( - DaxTranslationError, - match="REMOVECOLUMNS column 'ProductKey' is ambiguous in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - ) - - -def test_translate_query_evaluate_renamecolumns_table(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS('Sales', 'Sales'[Amount], "Amt") - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * RENAME (Amount AS Amt) FROM (SELECT * FROM Sales) AS t" in sql - - -def test_translate_query_evaluate_renamecolumns_wrapped_table(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS( - SELECTCOLUMNS('Sales', "Amt", 'Sales'[Amount], "Qty", 'Sales'[Quantity]), - "Qty", "QuantityRenamed" - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT Amount AS Amt, Quantity AS Qty FROM Sales" in sql - assert "SELECT * RENAME (Qty AS QuantityRenamed) FROM (" in sql - - -def test_translate_query_evaluate_calculatetable_renamecolumns_preserves_underlying_filters(): - query = _parse_query( - """ - EVALUATE - CALCULATETABLE( - 'Sales', - RENAMECOLUMNS(FILTER('Sales', 'Sales'[Amount] > 100), 'Sales'[Amount], "Amt") - ) - """ - ) - - translation = translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - sql = translation.evaluates[0].sql - assert "SELECT * FROM Sales WHERE (Sales.Amount > 100)" in sql - - -def test_translate_query_evaluate_renamecolumns_requires_old_new_pairs(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS('Sales', 'Sales'[Amount]) - """ - ) - - with pytest.raises( - DaxTranslationError, - match="RENAMECOLUMNS requires a table expression and at least one old/new column pair", - ): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_evaluate_renamecolumns_requires_even_old_new_pairs(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS('Sales', 'Sales'[Amount], "Amt", 'Sales'[Quantity]) - """ - ) - - with pytest.raises(DaxTranslationError, match="RENAMECOLUMNS requires old/new column argument pairs"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - -def test_translate_query_evaluate_renamecolumns_rejects_missing_input_source_column(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS('Sales', 'Products'[Weight], "WeightRenamed") - """ - ) - - with pytest.raises( - DaxTranslationError, - match="RENAMECOLUMNS column 'Weight' references table 'Products' not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - ) - - -def test_translate_query_evaluate_renamecolumns_rejects_wrong_qualified_source_table_reference(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS('Sales', 'Products'[Amount], "Amt") - """ - ) - - with pytest.raises( - DaxTranslationError, - match="RENAMECOLUMNS column 'Amount' references table 'Products' not present in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Amount": "Amount", "Weight": "Weight"}, - }, - ) - - -def test_translate_query_evaluate_renamecolumns_rejects_duplicate_source_columns(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS('Sales', 'Sales'[Amount], "Amt", 'Sales'[Amount], "AmountAgain") - """ - ) - - with pytest.raises(DaxTranslationError, match="RENAMECOLUMNS source columns must be unique"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - -def test_translate_query_evaluate_renamecolumns_rejects_duplicate_target_columns(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS('Sales', 'Sales'[Amount], "Value", 'Sales'[Quantity], "Value") - """ - ) - - with pytest.raises(DaxTranslationError, match="RENAMECOLUMNS target column names must be unique"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount", "Quantity": "Quantity"}}, - ) - - -def test_translate_query_evaluate_renamecolumns_rejects_ambiguous_duplicate_input_source_column(): - query = _parse_query( - """ - EVALUATE - RENAMECOLUMNS(CROSSJOIN('Sales', 'Products'), "ProductKey", "KeyRenamed") - """ - ) - - with pytest.raises( - DaxTranslationError, - match="RENAMECOLUMNS source column 'ProductKey' is ambiguous in input table expression", - ): - translate_dax_query( - query, - column_sql_by_table={ - "Sales": {"Amount": "Amount", "ProductKey": "ProductKey"}, - "Products": {"Weight": "Weight", "ProductKey": "ProductKey"}, - }, - ) - - -def test_translate_query_define_function_arity_error(): - query = _parse_query( - """ - DEFINE - FUNCTION f = (x : NUMERIC) => x + 1 - EVALUATE - SELECTCOLUMNS('Sales', "x", f('Sales'[Amount], 2)) - """ - ) - - with pytest.raises(DaxTranslationError, match="expects 1 args, got 2"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) - - -def test_translate_query_define_function_cycle_error(): - query = _parse_query( - """ - DEFINE - FUNCTION loop = (x : NUMERIC) => loop(x) - EVALUATE - SELECTCOLUMNS('Sales', "x", loop('Sales'[Amount])) - """ - ) - - with pytest.raises(DaxTranslationError, match="Cyclic DEFINE FUNCTION reference"): - translate_dax_query( - query, - column_sql_by_table={"Sales": {"Amount": "Amount"}}, - ) diff --git a/tests/dax/test_runtime.py b/tests/dax/test_runtime.py deleted file mode 100644 index c8cc7e54..00000000 --- a/tests/dax/test_runtime.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Tests for DAX runtime translation context helpers.""" - -from sidemantic.core.metric import Metric -from sidemantic.core.model import Model -from sidemantic.core.relationship import Relationship -from sidemantic.core.semantic_graph import SemanticGraph -from sidemantic.dax.runtime import build_dax_translation_context - - -def test_build_dax_translation_context_includes_many_to_many_edges(): - sales = Model( - name="Sales", - table="sales", - primary_key="SalesKey", - relationships=[ - Relationship( - name="Products", - type="many_to_many", - foreign_key="ProductKey", - primary_key="ProductKey", - ) - ], - ) - products = Model(name="Products", table="products", primary_key="ProductKey") - - graph = SemanticGraph() - graph.add_model(sales) - graph.add_model(products) - - context = build_dax_translation_context(graph) - edges = context["relationship_edges"] - assert len(edges) == 1 - assert edges[0].from_table == "Sales" - assert edges[0].from_column == "ProductKey" - assert edges[0].to_table == "Products" - assert edges[0].to_column == "ProductKey" - - -def test_build_dax_translation_context_deduplicates_reverse_relationship_edges(): - sales = Model( - name="Sales", - table="sales", - primary_key="SalesKey", - relationships=[ - Relationship( - name="Products", - type="many_to_many", - foreign_key="ProductKey", - primary_key="ProductKey", - ) - ], - ) - products = Model( - name="Products", - table="products", - primary_key="ProductKey", - relationships=[ - Relationship( - name="Sales", - type="many_to_many", - foreign_key="ProductKey", - primary_key="ProductKey", - ) - ], - ) - - graph = SemanticGraph() - graph.add_model(sales) - graph.add_model(products) - - context = build_dax_translation_context(graph) - edges = context["relationship_edges"] - assert len(edges) == 1 - - -def test_build_dax_translation_context_uses_tmdl_from_column_for_one_to_many_edges(): - products_to_customers = Relationship( - name="Customers", - type="one_to_many", - foreign_key="ProductKey", - ) - products_to_customers._tmdl_from_column = "ProductKey" - products = Model( - name="Products", - table="products", - primary_key="InternalProductId", - relationships=[products_to_customers], - ) - customers = Model(name="Customers", table="customers", primary_key="CustomerKey") - - graph = SemanticGraph() - graph.add_model(products) - graph.add_model(customers) - - context = build_dax_translation_context(graph) - edges = context["relationship_edges"] - assert len(edges) == 1 - assert edges[0].from_table == "Products" - assert edges[0].from_column == "ProductKey" - assert edges[0].to_table == "Customers" - assert edges[0].to_column == "ProductKey" - - -def test_build_dax_translation_context_includes_measure_sql_metadata(): - sales = Model( - name="Sales", - table="sales", - primary_key="SalesKey", - metrics=[Metric(name="Total Sales", agg="sum", sql="amount")], - ) - - graph = SemanticGraph() - graph.add_model(sales) - - context = build_dax_translation_context(graph) - assert context["measure_sql_by_table"]["Sales"]["Total Sales"] == "amount" - - -def test_build_dax_translation_context_includes_measure_filters(): - sales = Model( - name="Sales", - table="sales", - primary_key="SalesKey", - metrics=[ - Metric( - name="West Sales", - agg="sum", - sql="Amount", - filters=["Sales.Region = 'West'"], - ) - ], - ) - - graph = SemanticGraph() - graph.add_model(sales) - - context = build_dax_translation_context(graph) - assert context["measure_filters_by_table"]["Sales"]["West Sales"] == ["Sales.Region = 'West'"] diff --git a/tests/dax/test_translation.py b/tests/dax/test_translation.py deleted file mode 100644 index e5f1925c..00000000 --- a/tests/dax/test_translation.py +++ /dev/null @@ -1,6070 +0,0 @@ -from __future__ import annotations - -import pytest -import sidemantic_dax - -from sidemantic.dax import ( - DaxTranslationError, - RelationshipEdge, - translate_dax_metric, - translate_dax_query, - translate_dax_scalar, - translate_dax_table, -) - - -def _parse_expression(expr: str): - try: - return sidemantic_dax.parse_expression(expr) - except RuntimeError as exc: - if "native module is not available" in str(exc): - pytest.skip("sidemantic_dax native module not available") - raise - - -def _parse_query(query: str): - try: - return sidemantic_dax.parse_query(query) - except RuntimeError as exc: - if "native module is not available" in str(exc): - pytest.skip("sidemantic_dax native module not available") - raise - - -def _sales_maps(): - column_sql_by_table = { - "sales": { - "amount": "amount", - "product_key": "product_key", - "quantity": "quantity", - "order_date": "order_date", - } - } - measure_names_by_table = {"sales": {"Total Sales"}} - time_dimensions_by_table = {"sales": {"order_date"}} - return column_sql_by_table, measure_names_by_table, time_dimensions_by_table - - -def test_translate_calculate_with_filter(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_metric_reference_uses_measure_metadata(): - expr = _parse_expression("[Total Sales]") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, - measure_sql_by_table={"sales": {"Total Sales": "amount"}}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.source_table == "sales" - - -def test_translate_scalar_allows_multi_table_when_model_is_none(): - expr = _parse_expression("'Sales'[Amount] + 'Tax'[Rate]") - sql = translate_dax_scalar( - expr, - model_name=None, - column_sql_by_table={ - "Sales": {"Amount": "Amount"}, - "Tax": {"Rate": "Rate"}, - }, - ) - - assert sql == "(Sales.Amount + Tax.Rate)" - - -def test_translate_calculate_filter_argument_propagates_table_filters(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "FILTER(CALCULATETABLE('sales', 'sales'[quantity] = 2), 'sales'[product_key] = 1)" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(quantity = 2)", "(product_key = 1)"] - - -def test_translate_calculate_keepfilters_filter_argument_preserves_inherited_filter(): - expr = _parse_expression( - "CALCULATE(" - "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 3), " - "KEEPFILTERS(FILTER(CALCULATETABLE('sales', 'sales'[quantity] = 2), 'sales'[product_key] = 1))" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 3)", "(quantity = 2)", "(product_key = 1)"] - - -def test_translate_derived_countrows_table_expression_keeps_filters(): - expr = _parse_expression("DIVIDE(COUNTROWS(CALCULATETABLE('sales', 'sales'[product_key] = 1)), COUNTROWS('sales'))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert "COUNT(CASE WHEN (product_key = 1) THEN 1 END)" in translation.sql - assert "COUNT(*)" in translation.sql - - -def test_translate_sumx_row_expression(): - expr = _parse_expression("SUMX('sales', 'sales'[amount] * 'sales'[quantity])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "(amount * quantity)" - - -def test_translate_sumx_filter_all_expression(): - expr = _parse_expression("SUMX(FILTER(ALL('sales'), 'sales'[product_key] = 1), 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_averagex_cross_table_row_expression(): - expr = _parse_expression("AVERAGEX('sales', 'sales'[amount] * 'products'[weight])") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "order_date": "order_date"}, - "products": {"weight": "weight"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "avg" - assert translation.sql == "(amount * products.weight)" - assert "products" in translation.required_models - - -def test_translate_median_aggregate_expression(): - expr = _parse_expression("MEDIAN('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "median" - assert translation.sql == "amount" - - -def test_translate_medianx_row_expression(): - expr = _parse_expression("MEDIANX('sales', 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "median" - assert translation.sql == "amount" - - -def test_translate_sumx_topn_table_expression(): - expr = _parse_expression("SUMX(TOPN(10, 'sales', 'sales'[amount], DESC), 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - - -def test_translate_sumx_selectcolumns_filter_expression(): - expr = _parse_expression( - "SUMX(SELECTCOLUMNS(FILTER('sales', 'sales'[product_key] = 1), \"Amount\", 'sales'[amount]), 'sales'[amount])" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_sumx_addcolumns_filter_expression(): - expr = _parse_expression("SUMX(ADDCOLUMNS(FILTER('sales', 'sales'[product_key] = 1), \"X\", 1), 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_sumx_topn_over_filtered_table_expression(): - expr = _parse_expression( - "SUMX(TOPN(5, FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount], DESC), 'sales'[amount])" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_avgx_topn_over_filtered_table_expression(): - expr = _parse_expression( - "AVGX(TOPN(5, FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount], DESC), 'sales'[amount])" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "avg" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_maxx_row_expression(): - expr = _parse_expression("MAXX('sales', 'sales'[amount] * 'sales'[quantity])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "max" - assert translation.sql == "(amount * quantity)" - - -def test_translate_countx_filtered_table_expression(): - expr = _parse_expression("COUNTX(FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_countax_filtered_table_expression(): - expr = _parse_expression("COUNTAX(FILTER('sales', 'sales'[product_key] = 1), 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_totalytd_cumulative(): - expr = _parse_expression("TOTALYTD(SUM('sales'[amount]), 'sales'[order_date])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.grain_to_date == "year" - assert translation.agg == "sum" - assert translation.sql == "amount" - - -def test_translate_totalytd_preserves_inherited_filters(): - expr = _parse_expression("TOTALYTD(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), 'sales'[order_date])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.grain_to_date == "year" - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_totalytd_filter_arg_replaces_inherited_filter(): - expr = _parse_expression( - "TOTALYTD(" - "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), " - "'sales'[order_date], " - "'sales'[product_key] = 2" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.filters == ["(product_key = 2)"] - - -def test_translate_totalytd_ignores_year_end_literal_arg(): - expr = _parse_expression("TOTALYTD(SUM('sales'[amount]), 'sales'[order_date], \"6/30\")") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.filters == [] - - -def test_translate_totalmtd_cumulative_with_filter_arg(): - expr = _parse_expression("TOTALMTD(SUM('sales'[amount]), 'sales'[order_date], 'sales'[product_key] = 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.grain_to_date == "month" - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_totalwtd_cumulative_with_filter_arg(): - expr = _parse_expression("TOTALWTD(SUM('sales'[amount]), 'sales'[order_date], 'sales'[product_key] = 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.grain_to_date == "week" - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_totalytd_cross_table_time_column_and_table_filter_candidate(): - expr = _parse_expression("TOTALYTD(SUM('sales'[amount]), 'date'[date_key], 'date')") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, - ) - - assert translation.type == "cumulative" - assert translation.grain_to_date == "year" - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.window_order == "date.date_key" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_totalqtd_preserves_inherited_filters(): - expr = _parse_expression("TOTALQTD(CALCULATE(SUM('sales'[amount]), 'sales'[quantity] = 2), 'sales'[order_date])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.grain_to_date == "quarter" - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(quantity = 2)"] - - -def test_translate_calculate_sameperiodlastyear(): - expr = _parse_expression("CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[order_date]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - - -def test_translate_calculate_sameperiodlastyear_cross_table_time_column(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), SAMEPERIODLASTYEAR('date'[date_key]))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, - ) - - assert translation.type == "time_comparison" - assert translation.inline_base_agg == "sum" - assert translation.inline_base_sql == "amount" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.window_order == "date.date_key" - assert "date" in translation.required_models - - -def test_translate_calculate_datesytd_as_cumulative(): - expr = _parse_expression("CALCULATE([Total Sales], DATESYTD('sales'[order_date]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.sql == "sales.Total Sales" - assert translation.agg == "sum" - assert translation.grain_to_date == "year" - - -def test_translate_calculate_datesmtd_inline_agg_as_cumulative(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DATESMTD('sales'[order_date]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.sql == "amount" - assert translation.agg == "sum" - assert translation.grain_to_date == "month" - - -def test_translate_calculate_dateswtd_inline_agg_as_cumulative(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DATESWTD('sales'[order_date]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.sql == "amount" - assert translation.agg == "sum" - assert translation.grain_to_date == "week" - - -def test_translate_calculate_datesinperiod_inline_agg_as_cumulative_window(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), DATESINPERIOD('sales'[order_date], MAX('sales'[order_date]), -3, MONTH))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.sql == "amount" - assert translation.agg == "sum" - assert translation.window == "3 month" - - -def test_translate_calculate_datesinperiod_measure_ref_as_cumulative_window(): - expr = _parse_expression( - "CALCULATE([Total Sales], DATESINPERIOD('sales'[order_date], MAX('sales'[order_date]), -1, YEAR))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.sql == "sales.Total Sales" - assert translation.agg == "sum" - assert translation.window == "1 year" - - -def test_translate_calculate_datesinperiod_positive_inline_agg_as_forward_cumulative_window(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), DATESINPERIOD('sales'[order_date], MAX('sales'[order_date]), 3, MONTH))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.sql == "amount" - assert translation.agg == "sum" - assert translation.window == "3 month following" - - -def test_translate_calculate_keepfilters_datesqtd_with_additional_filter(): - expr = _parse_expression( - "CALCULATE([Total Sales], KEEPFILTERS(DATESQTD('sales'[order_date])), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={"sales": {"Total Sales": "sum"}}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "cumulative" - assert translation.sql == "sales.Total Sales" - assert translation.agg == "sum" - assert translation.grain_to_date == "quarter" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_sameperiodlastyear_with_inline_aggregate_base(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), SAMEPERIODLASTYEAR('sales'[order_date]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric is None - assert translation.inline_base_agg == "sum" - assert translation.inline_base_sql == "amount" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - - -def test_translate_calculate_sameperiodlastyear_with_additional_filter(): - expr = _parse_expression( - "CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[order_date]), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_time_intelligence_allows_additional_time_filter_function(): - expr = _parse_expression( - "CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[order_date]), DATESYTD('sales'[order_date]))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.filters - assert any("order_date" in clause for clause in translation.filters or []) - - -def test_translate_calculate_dateadd_with_additional_filter(): - expr = _parse_expression( - "CALCULATE([Total Sales], DATEADD('sales'[order_date], -1, YEAR), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.time_offset == "1 year" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_dateadd_forward_offset_sets_negative_time_offset(): - expr = _parse_expression( - "CALCULATE([Total Sales], DATEADD('sales'[order_date], 1, YEAR), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.time_offset == "-1 year" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_dateadd_plural_unit_normalizes_to_yoy(): - expr = _parse_expression( - "CALCULATE([Total Sales], DATEADD('sales'[order_date], -1, YEARS), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.time_offset == "1 year" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_parallelperiod_with_additional_filter(): - expr = _parse_expression( - "CALCULATE([Total Sales], PARALLELPERIOD('sales'[order_date], -1, YEAR), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.time_offset == "1 year" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_previousyear_with_additional_filter(): - expr = _parse_expression("CALCULATE([Total Sales], PREVIOUSYEAR('sales'[order_date]), 'sales'[product_key] = 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.time_offset == "1 year" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_nextmonth_sets_negative_time_offset(): - expr = _parse_expression("CALCULATE([Total Sales], NEXTMONTH('sales'[order_date]), 'sales'[product_key] = 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "mom" - assert translation.calculation == "previous_value" - assert translation.time_offset == "-1 month" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_keepfilters_sameperiodlastyear_with_additional_filter(): - expr = _parse_expression( - "CALCULATE([Total Sales], KEEPFILTERS(SAMEPERIODLASTYEAR('sales'[order_date])), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_keepfilters_dateadd_with_additional_filter(): - expr = _parse_expression( - "CALCULATE([Total Sales], KEEPFILTERS(DATEADD('sales'[order_date], -1, YEAR)), 'sales'[product_key] = 1)" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "time_comparison" - assert translation.base_metric == "sales.Total Sales" - assert translation.comparison_type == "yoy" - assert translation.calculation == "previous_value" - assert translation.time_offset == "1 year" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_summarizecolumns_table(): - expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], \"Revenue\", SUM('sales'[amount]))") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert ( - translation.sql - == "SELECT sales.product_key, SUM(sales.amount) AS Revenue FROM sales GROUP BY sales.product_key" - ) - - -def test_translate_summarize_table(): - expr = _parse_expression("SUMMARIZE('sales', 'sales'[product_key], \"Revenue\", SUM('sales'[amount]))") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert ( - translation.sql - == "SELECT sales.product_key, SUM(sales.amount) AS Revenue FROM sales GROUP BY sales.product_key" - ) - - -def test_translate_summarize_wrapped_row_group_by_bracket_alias(): - expr = _parse_expression('SUMMARIZE(ROW("x", 1, "y", 2), [x])') - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={}, - measure_names_by_table={}, - ) - - assert translation.sql == "SELECT x FROM (SELECT 1 AS x, 2 AS y) AS t GROUP BY x" - - -def test_translate_summarize_wrapped_multitable_row_group_by_bracket_alias(): - expr = _parse_expression("SUMMARIZE(ROW(\"x\", 'sales'[amount], \"d\", 'date'[date_key]), [x])") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert ( - "SELECT x FROM (SELECT amount AS x, date.date_key AS d FROM sales CROSS JOIN date) AS t GROUP BY x" - in translation.sql - ) - assert "sales" in translation.required_models - assert "date" in translation.required_models - - -def test_translate_summarize_wrapped_row_group_by_identifier_alias(): - expr = _parse_expression('SUMMARIZE(ROW("x", 1, "y", 2), x)') - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={}, - measure_names_by_table={}, - ) - - assert translation.sql == "SELECT x FROM (SELECT 1 AS x, 2 AS y) AS t GROUP BY x" - - -def test_translate_summarizecolumns_rollupgroup_table(): - expr = _parse_expression("SUMMARIZECOLUMNS(ROLLUPGROUP('sales'[product_key]), \"Rows\", COUNTROWS('sales'))") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" - - -def test_translate_summarizecolumns_rollupaddissubtotal_table(): - expr = _parse_expression( - "SUMMARIZECOLUMNS(ROLLUPADDISSUBTOTAL('sales'[product_key], \"is_subtotal\"), \"Rows\", COUNTROWS('sales'))" - ) - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" - - -def test_translate_summarizecolumns_all_filter_table_arg(): - expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], ALL('sales'), \"Rows\", COUNTROWS('sales'))") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" - - -def test_translate_summarizecolumns_values_filter_table_arg(): - expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], VALUES('sales'), \"Rows\", COUNTROWS('sales'))") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" - - -def test_translate_summarizecolumns_rollupissubtotal_table(): - expr = _parse_expression("SUMMARIZECOLUMNS(ROLLUPISSUBTOTAL('sales'[product_key]), \"Rows\", COUNTROWS('sales'))") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" - - -def test_translate_summarize_rollup_table(): - expr = _parse_expression("SUMMARIZE('sales', ROLLUP('sales'[product_key]), \"Rows\", COUNTROWS('sales'))") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT sales.product_key, COUNT(*) AS Rows FROM sales GROUP BY sales.product_key" - - -def test_translate_selectcolumns_table_keeps_base_columns_in_scope(): - expr = _parse_expression("SELECTCOLUMNS('sales', \"Amount\", 'sales'[amount])") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT amount AS Amount FROM sales" - - -def test_translate_selectcolumns_table_with_cross_table_expression_joins_related_table(): - expr = _parse_expression("SELECTCOLUMNS('sales', \"Weight\", 'products'[weight])") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key", "weight": "weight"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql - == "SELECT products.weight AS Weight FROM sales LEFT JOIN products ON sales.product_key = products.product_key" - ) - assert "products" in translation.required_models - - -def test_translate_addcolumns_table_with_cross_table_expression_joins_related_table(): - expr = _parse_expression("ADDCOLUMNS('sales', \"Weight\", 'products'[weight])") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key", "weight": "weight"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql - == "SELECT sales.*, products.weight AS Weight FROM sales LEFT JOIN products ON sales.product_key = products.product_key" - ) - assert "products" in translation.required_models - - -def test_translate_selectcolumns_wrapped_base_with_cross_table_expression_joins_related_table(): - expr = _parse_expression("SELECTCOLUMNS(FILTER('sales', 'sales'[amount] > 0), \"Category\", 'products'[category])") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql == "SELECT products.category AS Category FROM (SELECT * FROM sales WHERE (amount > 0)) AS t " - "LEFT JOIN products ON t.product_key = products.product_key" - ) - assert "products" in translation.required_models - - -def test_translate_addcolumns_wrapped_base_with_cross_table_expression_cross_joins_when_unrelated(): - expr = _parse_expression("ADDCOLUMNS(FILTER('sales', 'sales'[amount] > 0), \"Rate\", 'tax'[rate])") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "tax": {"rate": "rate"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert ( - translation.sql - == "SELECT t.*, tax.rate AS Rate FROM (SELECT * FROM sales WHERE (amount > 0)) AS t CROSS JOIN tax" - ) - assert "tax" in translation.required_models - - -def test_translate_topn_base_table_with_cross_table_order_by_joins_related_table(): - expr = _parse_expression("TOPN(2, 'sales', 'products'[weight], DESC)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key", "weight": "weight"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql == "SELECT sales.* FROM sales LEFT JOIN products ON sales.product_key = products.product_key " - "ORDER BY products.weight DESC LIMIT 2" - ) - assert "products" in translation.required_models - - -def test_translate_topn_accepts_scalar_count_expression(): - expr = _parse_expression("TOPN(1 + 1, 'sales', 'sales'[amount], DESC)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={"sales": set()}, - ) - - assert "ORDER BY amount DESC" in translation.sql - assert "LIMIT CAST(((1 + 1)) AS BIGINT)" in translation.sql - - -def test_translate_topnskip_base_table_with_cross_table_order_by_cross_joins_when_unrelated(): - expr = _parse_expression("TOPNSKIP(2, 1, 'sales', 'tax'[rate], ASC)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "tax": {"rate": "rate"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert translation.sql == "SELECT sales.* FROM sales CROSS JOIN tax ORDER BY tax.rate ASC LIMIT 2 OFFSET 1" - assert "tax" in translation.required_models - - -def test_translate_topnskip_accepts_scalar_count_and_skip_expressions(): - expr = _parse_expression("TOPNSKIP(3 - 1, 5 / 2, 'sales', 'sales'[amount], DESC)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={"sales": set()}, - ) - - assert "ORDER BY amount DESC" in translation.sql - assert "LIMIT CAST(((3 - 1)) AS BIGINT)" in translation.sql - assert "OFFSET CAST(((5 / 2)) AS BIGINT)" in translation.sql - - -def test_translate_topn_wrapped_base_with_cross_table_order_by_joins_related_table(): - expr = _parse_expression("TOPN(2, FILTER('sales', 'sales'[amount] > 0), 'products'[weight], DESC)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key", "weight": "weight"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql - == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 0)) AS t LEFT JOIN products ON t.product_key = products.product_key " - "ORDER BY products.weight DESC LIMIT 2" - ) - assert "products" in translation.required_models - - -def test_translate_topnperlevel_base_table_with_cross_table_group_order_cross_joins_when_unrelated(): - expr = _parse_expression("TOPNPERLEVEL(1, 'tax'[region], 'sales', 'tax'[rate], DESC)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "tax": {"region": "region", "rate": "rate"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert ( - translation.sql - == "SELECT * EXCLUDE (__topnperlevel_rank) FROM (SELECT sales.*, RANK() OVER (PARTITION BY tax.region " - "ORDER BY tax.rate DESC) AS __topnperlevel_rank FROM sales CROSS JOIN tax) AS q " - "WHERE __topnperlevel_rank <= 1" - ) - assert "tax" in translation.required_models - - -def test_translate_topnperlevel_wrapped_base_with_cross_table_group_order_joins_related_table(): - expr = _parse_expression( - "TOPNPERLEVEL(1, 'products'[category], FILTER('sales', 'sales'[amount] > 0), 'products'[weight], DESC)" - ) - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category", "weight": "weight"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql - == "SELECT * EXCLUDE (__topnperlevel_rank) FROM (SELECT t.*, RANK() OVER (PARTITION BY products.category " - "ORDER BY products.weight DESC) AS __topnperlevel_rank FROM (SELECT * FROM sales WHERE (amount > 0)) AS t " - "LEFT JOIN products ON t.product_key = products.product_key) AS q WHERE __topnperlevel_rank <= 1" - ) - assert "products" in translation.required_models - - -def test_translate_filter_table_keeps_base_columns_in_scope(): - expr = _parse_expression("FILTER('sales', 'sales'[amount] > 100)") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT * FROM sales WHERE (amount > 100)" - - -def test_translate_filter_table_with_cross_table_predicate_joins_related_table(): - expr = _parse_expression("FILTER('sales', 'products'[weight] > 0)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"weight": "weight", "product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql == "SELECT sales.* FROM sales LEFT JOIN products ON sales.product_key = products.product_key " - "WHERE (products.weight > 0)" - ) - assert "products" in translation.required_models - - -def test_translate_filter_table_with_cross_table_predicate_cross_joins_when_unrelated(): - expr = _parse_expression("FILTER('sales', 'tax'[rate] > 0)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "tax": {"rate": "rate"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert translation.sql == "SELECT sales.* FROM sales CROSS JOIN tax WHERE (tax.rate > 0)" - assert "tax" in translation.required_models - - -def test_translate_filter_wrapped_base_with_cross_table_predicate_joins_related_table(): - expr = _parse_expression("FILTER(FILTER('sales', 'sales'[amount] > 0), 'products'[category] = \"Clothing\")") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql - == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 0)) AS t LEFT JOIN products ON t.product_key = products.product_key " - "WHERE (products.category = 'Clothing')" - ) - assert "products" in translation.required_models - - -def test_translate_filter_wrapped_base_with_cross_table_predicate_cross_joins_when_unrelated(): - expr = _parse_expression("FILTER(FILTER('sales', 'sales'[amount] > 0), 'tax'[rate] > 0)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "tax": {"rate": "rate"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert ( - translation.sql - == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 0)) AS t CROSS JOIN tax WHERE (tax.rate > 0)" - ) - assert "tax" in translation.required_models - - -def test_translate_calculatetable_table(): - expr = _parse_expression("CALCULATETABLE('sales', 'sales'[product_key] = 1)") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT * FROM sales WHERE (product_key = 1)" - - -def test_translate_calculatetable_base_table_with_cross_table_filter_joins_related_table(): - expr = _parse_expression("CALCULATETABLE('sales', 'products'[category] = \"Clothing\")") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql == "SELECT sales.* FROM sales LEFT JOIN products ON sales.product_key = products.product_key " - "WHERE (products.category = 'Clothing')" - ) - assert "products" in translation.required_models - - -def test_translate_calculatetable_base_table_with_cross_table_table_ref_filter_candidate(): - expr = _parse_expression("CALCULATETABLE('sales', 'date')") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert translation.sql == "SELECT * FROM sales" - assert "date" in translation.required_models - - -def test_translate_calculatetable_base_table_with_cross_table_filter_cross_joins_when_unrelated(): - expr = _parse_expression("CALCULATETABLE('sales', 'tax'[rate] > 0)") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "tax": {"rate": "rate"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert translation.sql == "SELECT sales.* FROM sales CROSS JOIN tax WHERE (tax.rate > 0)" - assert "tax" in translation.required_models - - -def test_translate_calculatetable_base_table_with_datesinperiod_filter(): - expr = _parse_expression("CALCULATETABLE('sales', DATESINPERIOD('sales'[order_date], \"2024-12-31\", -3, MONTH))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={"sales": {"order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - ) - - assert ( - translation.sql - == "SELECT * FROM sales WHERE (order_date > ('2024-12-31' + INTERVAL '-3 month') AND order_date <= '2024-12-31')" - ) - - -def test_translate_calculatetable_base_table_with_datesytd_filter(): - expr = _parse_expression("CALCULATETABLE('sales', DATESYTD('sales'[order_date]))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={"sales": {"order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - ) - - assert ( - translation.sql == "SELECT * FROM sales WHERE " - "(order_date >= DATE_TRUNC('year', (SELECT MAX(order_date) FROM sales)) " - "AND order_date <= (SELECT MAX(order_date) FROM sales))" - ) - - -def test_translate_calculatetable_base_table_with_cross_table_datesinperiod_filter_cross_joins_when_unrelated(): - expr = _parse_expression("CALCULATETABLE('sales', DATESINPERIOD('date'[date_key], \"2024-12-31\", -3, MONTH))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert ( - translation.sql - == "SELECT sales.* FROM sales CROSS JOIN date WHERE (date.date_key > ('2024-12-31' + INTERVAL '-3 month') AND date.date_key <= '2024-12-31')" - ) - assert "date" in translation.required_models - - -def test_translate_calculatetable_base_table_with_cross_table_datesqtd_filter_cross_joins_when_unrelated(): - expr = _parse_expression("CALCULATETABLE('sales', DATESQTD('date'[date_key]))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert ( - translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " - "(date.date_key >= DATE_TRUNC('quarter', (SELECT MAX(date.date_key) FROM date)) " - "AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" - ) - assert "date" in translation.required_models - - -def test_translate_calculatetable_base_table_with_cross_table_dateadd_filter_cross_joins_when_unrelated(): - expr = _parse_expression("CALCULATETABLE('sales', DATEADD('date'[date_key], -1, YEAR))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert ( - translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " - "(date.date_key > ((SELECT MAX(date.date_key) FROM date) + INTERVAL '-1 year') AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" - ) - assert "date" in translation.required_models - - -def test_translate_calculatetable_base_table_with_cross_table_sameperiodlastyear_filter_cross_joins_when_unrelated(): - expr = _parse_expression("CALCULATETABLE('sales', SAMEPERIODLASTYEAR('date'[date_key]))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert ( - translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " - "(date.date_key > ((SELECT MAX(date.date_key) FROM date) + INTERVAL '-1 year') AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" - ) - assert "date" in translation.required_models - - -def test_translate_calculatetable_base_table_with_cross_table_nextmonth_filter_cross_joins_when_unrelated(): - expr = _parse_expression("CALCULATETABLE('sales', NEXTMONTH('date'[date_key]))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert ( - translation.sql == "SELECT sales.* FROM sales CROSS JOIN date WHERE " - "(date.date_key >= (SELECT MAX(date.date_key) FROM date) AND date.date_key < ((SELECT MAX(date.date_key) FROM date) + INTERVAL '1 month'))" - ) - assert "date" in translation.required_models - - -def test_translate_calculatetable_wrapped_base_keeps_columns_in_scope(): - expr = _parse_expression("CALCULATETABLE(FILTER('sales', 'sales'[amount] > 100), 'sales'[amount] > 200)") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT * FROM (SELECT * FROM sales WHERE (amount > 100)) AS t WHERE (amount > 200)" - - -def test_translate_calculatetable_wrapped_base_with_cross_table_filter_joins_related_table(): - expr = _parse_expression( - "CALCULATETABLE(FILTER('sales', 'sales'[amount] > 100), 'products'[category] = \"Clothing\")" - ) - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql - == "SELECT t.* FROM (SELECT * FROM sales WHERE (amount > 100)) AS t LEFT JOIN products ON t.product_key = products.product_key " - "WHERE (products.category = 'Clothing')" - ) - assert "products" in translation.required_models - - -def test_translate_calculatetable_nested_replaces_same_column_filter(): - expr = _parse_expression( - "CALCULATETABLE(CALCULATETABLE('sales', 'sales'[product_key] = 1), 'sales'[product_key] = 2)" - ) - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT * FROM sales WHERE (product_key = 2)" - - -def test_translate_calculatetable_nested_keepfilters_preserves_inner_filter(): - expr = _parse_expression( - "CALCULATETABLE(CALCULATETABLE('sales', 'sales'[product_key] = 1), KEEPFILTERS('sales'[product_key] = 2))" - ) - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT * FROM sales WHERE (product_key = 1) AND (product_key = 2)" - - -def test_translate_calculatetable_nested_removefilters_clears_inner_filter(): - expr = _parse_expression( - "CALCULATETABLE(CALCULATETABLE('sales', 'sales'[product_key] = 1), REMOVEFILTERS('sales'[product_key]))" - ) - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT * FROM sales" - - -def test_translate_countrows_calculatetable_filters(): - expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', 'sales'[product_key] = 1))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_countrows_calculatetable_propagates_relationship_overrides(): - expr = _parse_expression( - "COUNTROWS(CALCULATETABLE('sales', USERELATIONSHIP('sales'[product_key], 'products'[product_key])))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "count" - assert translation.filters == [] - assert len(translation.relationship_overrides) == 1 - assert translation.relationship_overrides[0].join_type is None - assert translation.relationship_overrides[0].direction is None - - -def test_translate_countrows_calculatetable_datesinperiod_cross_table_filter(): - expr = _parse_expression( - "COUNTROWS(CALCULATETABLE('sales', DATESINPERIOD('date'[date_key], \"2024-12-31\", -3, MONTH)))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == [ - "(date.date_key > ('2024-12-31' + INTERVAL '-3 month') AND date.date_key <= '2024-12-31')" - ] - assert "date" in translation.required_models - - -def test_translate_countrows_calculatetable_datesmtd_cross_table_filter(): - expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', DATESMTD('date'[date_key])))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == [ - "(date.date_key >= DATE_TRUNC('month', (SELECT MAX(date.date_key) FROM date)) " - "AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" - ] - assert "date" in translation.required_models - - -def test_translate_countrows_calculatetable_dateadd_cross_table_filter(): - expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', DATEADD('date'[date_key], -1, YEAR)))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == [ - "(date.date_key > ((SELECT MAX(date.date_key) FROM date) + INTERVAL '-1 year') " - "AND date.date_key <= (SELECT MAX(date.date_key) FROM date))" - ] - assert "date" in translation.required_models - - -def test_translate_countrows_calculatetable_cross_table_table_ref_filter_candidate(): - expr = _parse_expression("COUNTROWS(CALCULATETABLE('sales', 'date'))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"date_key"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_calculate_filter_argument_propagates_relationship_overrides(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "FILTER(" - "CALCULATETABLE('sales', CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)), " - "'sales'[product_key] = 1" - ")" - ")" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key = 1)"] - assert len(translation.relationship_overrides) == 1 - assert translation.relationship_overrides[0].join_type == "inner" - assert translation.relationship_overrides[0].direction == "Both" - - -def test_translate_sumx_calculatetable_propagates_relationship_overrides(): - expr = _parse_expression( - "SUMX(" - "CALCULATETABLE('sales', CROSSFILTER('sales'[product_key], 'products'[product_key], NONE)), " - "'sales'[amount]" - ")" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert len(translation.relationship_overrides) == 1 - assert translation.relationship_overrides[0].join_type == "left" - assert translation.relationship_overrides[0].direction == "None" - - -def test_translate_values_table_column(): - expr = _parse_expression("VALUES('sales'[product_key])") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT DISTINCT product_key FROM sales" - - -def test_translate_distinct_table_ref(): - expr = _parse_expression("DISTINCT('sales')") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT DISTINCT * FROM (SELECT * FROM sales) AS t" - - -def test_translate_countrows_values_as_count_distinct(): - expr = _parse_expression("COUNTROWS(VALUES('sales'[product_key]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count_distinct" - assert translation.sql == "product_key" - assert translation.filters == [] - - -def test_translate_countrows_distinct_as_count_distinct(): - expr = _parse_expression("COUNTROWS(DISTINCT('sales'[product_key]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count_distinct" - assert translation.sql == "product_key" - assert translation.filters == [] - - -def test_translate_countrows_values_cross_table_column_as_count_distinct(): - expr = _parse_expression("COUNTROWS(VALUES('date'[date_key]))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count_distinct" - assert translation.sql == "date.date_key" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_countrows_filters_cross_table_column_as_count_distinct(): - expr = _parse_expression("COUNTROWS(FILTERS('date'[date_key]))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count_distinct" - assert translation.sql == "date.date_key" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_countrows_cross_table_identifier_table(): - expr = _parse_expression("COUNTROWS('date')") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_countrows_values_filtered_cross_table_propagates_filters(): - expr = _parse_expression("COUNTROWS(VALUES(FILTER('date', 'date'[date_key] = 1)))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == ["(date.date_key = 1)"] - assert "date" in translation.required_models - - -def test_translate_countrows_identifier_table(): - expr = _parse_expression("COUNTROWS(sales)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == [] - - -def test_translate_counta_aggregate(): - expr = _parse_expression("COUNTA('sales'[product_key])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql == "product_key" - assert translation.filters == [] - - -def test_translate_averagea_aggregate(): - expr = _parse_expression("AVERAGEA('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "avg" - assert translation.sql == "amount" - assert translation.filters == [] - - -def test_translate_mina_aggregate(): - expr = _parse_expression("MINA('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "min" - assert translation.sql == "amount" - assert translation.filters == [] - - -def test_translate_countblank_aggregate(): - expr = _parse_expression("COUNTBLANK('sales'[product_key])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql == "CASE WHEN product_key IS NULL THEN 1 END" - assert translation.filters == [] - - -def test_translate_distinctcountnoblank_aggregate(): - expr = _parse_expression("DISTINCTCOUNTNOBLANK('sales'[product_key])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count_distinct" - assert translation.sql == "product_key" - assert translation.filters == [] - - -def test_translate_approximatedistinctcount_aggregate(): - expr = _parse_expression("APPROXIMATEDISTINCTCOUNT('sales'[product_key])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count_distinct" - assert translation.sql == "product_key" - assert translation.filters == [] - - -def test_translate_selectedvalue_with_alternate(): - expr = _parse_expression("SELECTEDVALUE('sales'[product_key], -1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CASE WHEN COUNT(DISTINCT product_key) = 1 THEN MIN(product_key) ELSE -1 END" - - -def test_translate_selectedvalue_rejects_more_than_two_arguments(): - expr = _parse_expression("SELECTEDVALUE('sales'[product_key], -1, 0)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises( - DaxTranslationError, match="SELECTEDVALUE supports at most column and alternate_result arguments" - ): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_hasonevalue(): - expr = _parse_expression("HASONEVALUE('sales'[product_key])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(COUNT(DISTINCT product_key) = 1)" - - -def test_translate_hasonevalue_rejects_more_than_one_argument(): - expr = _parse_expression("HASONEVALUE('sales'[product_key], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="HASONEVALUE/HASONEFILTER supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_firstnonblank(): - expr = _parse_expression("FIRSTNONBLANK('sales'[product_key], 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "MIN(CASE WHEN amount IS NOT NULL THEN product_key ELSE NULL END)" - - -def test_translate_firstnonblank_rejects_more_than_two_arguments(): - expr = _parse_expression("FIRSTNONBLANK('sales'[product_key], 'sales'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises( - DaxTranslationError, match="FIRSTNONBLANK/LASTNONBLANK supports exactly column and expression arguments" - ): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_lastnonblank(): - expr = _parse_expression("LASTNONBLANK('sales'[product_key], 'sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "MAX(CASE WHEN amount IS NOT NULL THEN product_key ELSE NULL END)" - - -def test_translate_firstdate(): - expr = _parse_expression("FIRSTDATE('sales'[order_date])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "MIN(order_date)" - - -def test_translate_endofmonth(): - expr = _parse_expression("ENDOFMONTH('sales'[order_date])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "MIN(DATE_TRUNC('month', order_date) + INTERVAL '1 month' - INTERVAL '1 day')" - - -def test_translate_containsstring(): - expr = _parse_expression("CONTAINSSTRING('sales'[status], \"open\")") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "(POSITION(LOWER('open') IN LOWER(status)) > 0)" - - -def test_translate_containsstringexact(): - expr = _parse_expression("CONTAINSSTRINGEXACT('sales'[status], \"Open\")") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "(POSITION('Open' IN status) > 0)" - - -def test_translate_containsrow_table_constructor(): - expr = _parse_expression("CONTAINSROW({1, 2, 3}, 'sales'[product_key])") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"product_key": "product_key", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert ( - translation.sql - == "EXISTS (SELECT 1 FROM (SELECT 1 AS value1 UNION ALL SELECT 2 AS value1 UNION ALL SELECT 3 AS value1) " - "AS t(c1) WHERE t.c1 IS NOT DISTINCT FROM product_key)" - ) - - -def test_translate_containsrow_rejects_non_table_first_argument(): - expr = _parse_expression("CONTAINSROW(1, 1)") - with pytest.raises(DaxTranslationError, match="CONTAINSROW requires a table expression as first argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"product_key": "product_key", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_containsrow_rejects_value_count_mismatch(): - expr = _parse_expression( - "CONTAINSROW(SELECTCOLUMNS('sales', \"k\", 'sales'[product_key], \"q\", 'sales'[quantity]), 'sales'[product_key])" - ) - with pytest.raises(DaxTranslationError, match="CONTAINSROW value argument count must match table column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"product_key": "product_key", "quantity": "quantity", "order_date": "order_date"} - }, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_containsrow_rejects_non_inferable_table_width(): - expr = _parse_expression("CONTAINSROW('sales', 1)") - with pytest.raises(DaxTranslationError, match="CONTAINSROW requires an inferable table column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": set()}, - ) - - -def test_translate_containsstring_rejects_more_than_two_arguments(): - expr = _parse_expression('CONTAINSSTRING(\'sales\'[status], "open", "extra")') - with pytest.raises(DaxTranslationError, match="CONTAINSSTRING supports exactly two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_containsstringexact_rejects_more_than_two_arguments(): - expr = _parse_expression('CONTAINSSTRINGEXACT(\'sales\'[status], "Open", "extra")') - with pytest.raises(DaxTranslationError, match="CONTAINSSTRINGEXACT supports exactly two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_len(): - expr = _parse_expression("LEN('sales'[status])") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "LENGTH(status)" - - -def test_translate_len_rejects_more_than_one_argument(): - expr = _parse_expression("LEN('sales'[status], 1)") - with pytest.raises(DaxTranslationError, match="LEN supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_left(): - expr = _parse_expression("LEFT('sales'[status], 3)") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "SUBSTRING(status, 1, GREATEST(3, 0))" - - -def test_translate_left_default_num_chars(): - expr = _parse_expression("LEFT('sales'[status])") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "SUBSTRING(status, 1, GREATEST(1, 0))" - - -def test_translate_left_rejects_more_than_two_arguments(): - expr = _parse_expression("LEFT('sales'[status], 1, 2)") - with pytest.raises(DaxTranslationError, match="LEFT supports at most text and num_chars arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_right(): - expr = _parse_expression("RIGHT('sales'[status], 3)") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert ( - translation.sql == "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(status, GREATEST(LENGTH(status) - 3 + 1, 1), 3) END" - ) - - -def test_translate_right_rejects_more_than_two_arguments(): - expr = _parse_expression("RIGHT('sales'[status], 1, 2)") - with pytest.raises(DaxTranslationError, match="RIGHT supports at most text and num_chars arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_replace(): - expr = _parse_expression("REPLACE('sales'[status], 2, 2, \"xx\")") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert ( - translation.sql - == "(CASE WHEN GREATEST(2, 1) <= 1 THEN '' ELSE SUBSTRING(status, 1, GREATEST(2, 1) - 1) END || 'xx' || " - "SUBSTRING(status, GREATEST(2, 1) + GREATEST(2, 0)))" - ) - - -def test_translate_replace_requires_four_arguments(): - expr = _parse_expression("REPLACE('sales'[status], 2, 2)") - with pytest.raises( - DaxTranslationError, match="REPLACE requires old_text, start_num, num_chars, and new_text arguments" - ): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_substitute(): - expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy")') - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "REPLACE(status, 'ab', 'xy')" - - -def test_translate_substitute_instance_num(): - expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy", 1)') - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert ( - translation.sql - == "CASE WHEN 'ab' = '' THEN status WHEN (INSTR(status, 'ab')) = 0 THEN status ELSE SUBSTR(status, 1, " - "(INSTR(status, 'ab')) - 1) || 'xy' || SUBSTR(status, (INSTR(status, 'ab')) + LENGTH('ab')) END" - ) - - -def test_translate_substitute_instance_num_accepts_scalar_expression(): - expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy", 1 + 0)') - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert "STRING_SPLIT(status, 'ab')" in translation.sql - assert "CAST(((1 + 0)) AS BIGINT)" in translation.sql - - -def test_translate_substitute_instance_num_requires_positive_integer(): - expr = _parse_expression('SUBSTITUTE(\'sales\'[status], "ab", "xy", 0)') - with pytest.raises(DaxTranslationError, match="SUBSTITUTE instance_num must be >= 1"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_rept(): - expr = _parse_expression("REPT('sales'[status], 3)") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "REPEAT(status, GREATEST(CAST(FLOOR(3) AS BIGINT), 0))" - - -def test_translate_rept_requires_two_arguments(): - expr = _parse_expression("REPT('sales'[status])") - with pytest.raises(DaxTranslationError, match="REPT requires text and number_times arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_rept_rejects_more_than_two_arguments(): - expr = _parse_expression("REPT('sales'[status], 2, 3)") - with pytest.raises(DaxTranslationError, match="REPT supports exactly two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_trim(): - expr = _parse_expression("TRIM('sales'[status])") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "TRIM(REGEXP_REPLACE(CAST(status AS VARCHAR), ' +', ' ', 'g'))" - - -def test_translate_trim_requires_argument(): - expr = _parse_expression("TRIM()") - with pytest.raises(DaxTranslationError, match="TRIM requires an argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_trim_rejects_more_than_one_argument(): - expr = _parse_expression("TRIM('sales'[status], 'x')") - with pytest.raises(DaxTranslationError, match="TRIM supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_mid(): - expr = _parse_expression("MID('sales'[status], 2, 3)") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "CASE WHEN 3 <= 0 THEN '' ELSE SUBSTRING(status, GREATEST(2, 1), 3) END" - - -def test_translate_mid_requires_three_arguments(): - expr = _parse_expression("MID('sales'[status], 2)") - with pytest.raises(DaxTranslationError, match="MID requires text, start_num, and num_chars arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_mid_rejects_more_than_three_arguments(): - expr = _parse_expression("MID('sales'[status], 2, 3, 4)") - with pytest.raises(DaxTranslationError, match="MID requires text, start_num, and num_chars arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_exact(): - expr = _parse_expression("EXACT('sales'[status], \"Open\")") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.type == "derived" - assert translation.sql == "(status = 'Open')" - - -def test_translate_exact_rejects_more_than_two_arguments(): - expr = _parse_expression('EXACT(\'sales\'[status], "Open", "Closed")') - with pytest.raises(DaxTranslationError, match="EXACT supports exactly two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"status": "status", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_date_ctor(): - expr = _parse_expression("DATE(2024, 2, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "MAKE_DATE(2024, 2, 1)" - - -def test_translate_date_ctor_rejects_more_than_three_arguments(): - expr = _parse_expression("DATE(2024, 2, 1, 5)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="DATE requires year, month, and day arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_time_ctor(): - expr = _parse_expression("TIME(12, 30, 0)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "MAKE_TIME(12, 30, 0)" - - -def test_translate_time_ctor_rejects_more_than_three_arguments(): - expr = _parse_expression("TIME(12, 30, 0, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TIME requires hour, minute, and second arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_datevalue(): - expr = _parse_expression('DATEVALUE("2024-02-01")') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CAST('2024-02-01' AS DATE)" - - -def test_translate_datevalue_rejects_more_than_one_argument(): - expr = _parse_expression('DATEVALUE("2024-02-01", "extra")') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="DATEVALUE supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_timevalue(): - expr = _parse_expression('TIMEVALUE("12:34:56")') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CAST('12:34:56' AS TIME)" - - -def test_translate_timevalue_rejects_more_than_one_argument(): - expr = _parse_expression('TIMEVALUE("12:34:56", "extra")') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TIMEVALUE supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_edate(): - expr = _parse_expression('EDATE("2024-02-01", 2)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(CAST('2024-02-01' AS DATE) + (2) * INTERVAL '1 month')" - - -def test_translate_edate_rejects_more_than_two_arguments(): - expr = _parse_expression('EDATE("2024-02-01", 2, 1)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="EDATE requires start date and month offset arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_eomonth(): - expr = _parse_expression('EOMONTH("2024-02-01", 0)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == ( - "(DATE_TRUNC('month', (CAST('2024-02-01' AS DATE) + (0) * INTERVAL '1 month')) + INTERVAL '1 month' - " - "INTERVAL '1 day')" - ) - - -def test_translate_eomonth_rejects_more_than_two_arguments(): - expr = _parse_expression('EOMONTH("2024-02-01", 0, 1)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="EOMONTH requires start date and month offset arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_datediff(): - expr = _parse_expression('DATEDIFF("2024-01-01", "2024-02-01", MONTH)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "DATE_DIFF('month', CAST('2024-01-01' AS DATE), CAST('2024-02-01' AS DATE))" - - -def test_translate_datediff_rejects_more_than_three_arguments(): - expr = _parse_expression('DATEDIFF("2024-01-01", "2024-02-01", MONTH, DAY)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="DATEDIFF requires start date, end date, and interval arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_weekday(): - expr = _parse_expression('WEEKDAY("2024-02-01", 2)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(((EXTRACT(DOW FROM CAST('2024-02-01' AS DATE)) + 6) % 7) + 1)" - - -def test_translate_weekday_rejects_more_than_two_arguments(): - expr = _parse_expression('WEEKDAY("2024-02-01", 2, 3)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="WEEKDAY supports at most date and return_type arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_weekday_return_type_11(): - expr = _parse_expression('WEEKDAY("2024-02-01", 11)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(((EXTRACT(DOW FROM CAST('2024-02-01' AS DATE)) - 1 + 7) % 7) + 1)" - - -def test_translate_weeknum(): - expr = _parse_expression('WEEKNUM("2024-02-01", 2)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(CAST(STRFTIME(CAST('2024-02-01' AS DATE), '%W') AS INTEGER) + 1)" - - -def test_translate_weeknum_rejects_more_than_two_arguments(): - expr = _parse_expression('WEEKNUM("2024-02-01", 2, 3)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="WEEKNUM supports at most date and return_type arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_weeknum_return_type_21(): - expr = _parse_expression('WEEKNUM("2024-02-01", 21)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CAST(STRFTIME(CAST('2024-02-01' AS DATE), '%V') AS INTEGER)" - - -def test_translate_year_rejects_more_than_one_argument(): - expr = _parse_expression('YEAR("2024-02-01", 2)') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="YEAR supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_isinscope_defaults_false_without_group_context(): - expr = _parse_expression("ISINSCOPE('sales'[product_key])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "FALSE" - - -def test_translate_isinscope_rejects_more_than_one_argument(): - expr = _parse_expression("ISINSCOPE('sales'[product_key], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="ISINSCOPE supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_isfiltered_defaults_false_without_filter_context(): - expr = _parse_expression("ISFILTERED('sales'[product_key])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "FALSE" - - -def test_translate_isfiltered_rejects_more_than_one_argument(): - expr = _parse_expression("ISFILTERED('sales'[product_key], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="ISFILTERED/ISCROSSFILTERED supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_today(): - expr = _parse_expression("TODAY()") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CURRENT_DATE" - - -def test_translate_today_rejects_arguments(): - expr = _parse_expression("TODAY(1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TODAY does not take arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_now_rejects_arguments(): - expr = _parse_expression("NOW(1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="NOW does not take arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_utcnow_rejects_arguments(): - expr = _parse_expression("UTCNOW(1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="UTCNOW does not take arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_utctoday_rejects_arguments(): - expr = _parse_expression("UTCTODAY(1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="UTCTODAY does not take arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_rand(): - expr = _parse_expression("RAND()") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "RANDOM()" - - -def test_translate_rand_rejects_arguments(): - expr = _parse_expression("RAND(1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="RAND does not take arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_randbetween(): - expr = _parse_expression("RANDBETWEEN(1, 10)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CAST(FLOOR(RANDOM() * ((10) - (1) + 1) + (1)) AS BIGINT)" - - -def test_translate_randbetween_rejects_more_than_two_arguments(): - expr = _parse_expression("RANDBETWEEN(1, 10, 20)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="RANDBETWEEN supports exactly two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_format(): - expr = _parse_expression("FORMAT('sales'[amount], \"0.00\")") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CAST(amount AS VARCHAR)" - - -def test_translate_format_requires_format_string_argument(): - expr = _parse_expression("FORMAT('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="FORMAT requires value and format_string arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_format_rejects_more_than_three_arguments(): - expr = _parse_expression('FORMAT(\'sales\'[amount], "0.00", "en-US", "extra")') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="FORMAT supports at most value, format_string, and locale arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_iferror(): - expr = _parse_expression("IFERROR('sales'[amount], 0)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CASE WHEN amount IS NULL THEN 0 ELSE amount END" - - -def test_translate_iferror_rejects_more_than_two_arguments(): - expr = _parse_expression("IFERROR('sales'[amount], 0, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="IFERROR supports exactly two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_coalesce(): - expr = _parse_expression("COALESCE('sales'[amount], 0)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "COALESCE(amount, 0)" - - -def test_translate_coalesce_requires_at_least_two_arguments(): - expr = _parse_expression("COALESCE('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="COALESCE requires at least two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_switch_boolean_predicate_form(): - expr = _parse_expression("SWITCH(TRUE(), 'sales'[amount] > 0, 1, 0)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CASE WHEN (amount > 0) THEN 1 ELSE 0 END" - - -def test_translate_switch_requires_expression_and_value_result_pair(): - expr = _parse_expression("SWITCH('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - expr_missing_result = _parse_expression("SWITCH('sales'[amount], 1)") - with pytest.raises(DaxTranslationError, match="SWITCH requires expression and at least one value/result pair"): - translate_dax_metric( - expr_missing_result, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_divide_rejects_more_than_three_arguments(): - expr = _parse_expression("DIVIDE('sales'[amount], 2, 0, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises( - DaxTranslationError, - match="DIVIDE supports at most numerator, denominator, and alternate result arguments", - ): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_and_or_reject_more_than_two_arguments(): - and_expr = _parse_expression("AND(TRUE(), FALSE(), TRUE())") - or_expr = _parse_expression("OR(TRUE(), FALSE(), TRUE())") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - - with pytest.raises(DaxTranslationError, match="AND supports exactly two arguments"): - translate_dax_metric( - and_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - with pytest.raises(DaxTranslationError, match="OR supports exactly two arguments"): - translate_dax_metric( - or_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_roundup(): - expr = _parse_expression("ROUNDUP('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert ( - translation.sql == "CASE WHEN 2 >= 0 THEN SIGN(amount) * CEIL(ABS(amount) * POWER(10, 2)) / POWER(10, 2) ELSE " - "SIGN(amount) * CEIL(ABS(amount) / POWER(10, -(2))) * POWER(10, -(2)) END" - ) - - -def test_translate_round(): - expr = _parse_expression("ROUND('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert ( - translation.sql - == "CASE WHEN 2 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 2) + 0.5) / POWER(10, 2) ELSE " - "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(2)) + 0.5) * POWER(10, -(2)) END" - ) - - -def test_translate_round_requires_two_arguments(): - expr = _parse_expression("ROUND('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="ROUND requires number and num_digits arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_round_rejects_more_than_two_arguments(): - expr = _parse_expression("ROUND('sales'[amount], 2, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="ROUND requires number and num_digits arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_rounddown(): - expr = _parse_expression("ROUNDDOWN('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert ( - translation.sql == "CASE WHEN 2 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 2)) / POWER(10, 2) ELSE " - "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(2))) * POWER(10, -(2)) END" - ) - - -def test_translate_int(): - expr = _parse_expression("INT('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "FLOOR(amount)" - - -def test_translate_int_rejects_more_than_one_argument(): - expr = _parse_expression("INT('sales'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="INT supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_trunc(): - expr = _parse_expression("TRUNC('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert ( - translation.sql == "CASE WHEN 2 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 2)) / POWER(10, 2) ELSE " - "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(2))) * POWER(10, -(2)) END" - ) - - -def test_translate_trunc_defaults_num_digits_to_zero(): - expr = _parse_expression("TRUNC('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert ( - translation.sql == "CASE WHEN 0 >= 0 THEN SIGN(amount) * FLOOR(ABS(amount) * POWER(10, 0)) / POWER(10, 0) ELSE " - "SIGN(amount) * FLOOR(ABS(amount) / POWER(10, -(0))) * POWER(10, -(0)) END" - ) - - -def test_translate_trunc_rejects_more_than_two_arguments(): - expr = _parse_expression("TRUNC('sales'[amount], 2, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TRUNC supports at most number and num_digits arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_ceiling_with_significance(): - expr = _parse_expression("CEILING('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(CEIL(amount / 2) * 2)" - - -def test_translate_floor_with_significance(): - expr = _parse_expression("FLOOR('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(FLOOR(amount / 2) * 2)" - - -def test_translate_ceiling_floor_reject_more_than_two_arguments(): - ceiling_expr = _parse_expression("CEILING('sales'[amount], 2, 1)") - floor_expr = _parse_expression("FLOOR('sales'[amount], 2, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - - with pytest.raises(DaxTranslationError, match="CEILING supports at most number and significance arguments"): - translate_dax_metric( - ceiling_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - with pytest.raises(DaxTranslationError, match="FLOOR supports at most number and significance arguments"): - translate_dax_metric( - floor_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_mround(): - expr = _parse_expression("MROUND('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert ( - translation.sql == "CASE WHEN 2 = 0 THEN 0 ELSE SIGN(amount) * FLOOR((ABS(amount) / ABS(2)) + 0.5) * ABS(2) END" - ) - - -def test_translate_mround_requires_two_arguments(): - expr = _parse_expression("MROUND('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="MROUND requires number and multiple arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_mround_rejects_more_than_two_arguments(): - expr = _parse_expression("MROUND('sales'[amount], 2, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="MROUND requires number and multiple arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_min_aggregate_single_argument(): - expr = _parse_expression("MIN('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type is None - assert translation.agg == "min" - assert translation.sql == "amount" - - -def test_translate_max_aggregate_single_argument(): - expr = _parse_expression("MAX('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type is None - assert translation.agg == "max" - assert translation.sql == "amount" - - -def test_translate_min_scalar_two_arguments(): - expr = _parse_expression("MIN('sales'[amount], 10)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "LEAST(amount, 10)" - - -def test_translate_max_scalar_two_arguments(): - expr = _parse_expression("MAX('sales'[amount], 10)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "GREATEST(amount, 10)" - - -def test_translate_min_max_reject_more_than_two_arguments(): - min_expr = _parse_expression("MIN('sales'[amount], 10, 20)") - max_expr = _parse_expression("MAX('sales'[amount], 10, 20)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - - with pytest.raises(DaxTranslationError, match="MIN supports one aggregate argument or two scalar arguments"): - translate_dax_metric( - min_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - with pytest.raises(DaxTranslationError, match="MAX supports one aggregate argument or two scalar arguments"): - translate_dax_metric( - max_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_sum_rejects_more_than_one_argument(): - expr = _parse_expression("SUM('sales'[amount], 10)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - - with pytest.raises(DaxTranslationError, match="SUM supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_countrows_rejects_more_than_one_argument(): - expr = _parse_expression("COUNTROWS('sales', 'sales')") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - - with pytest.raises(DaxTranslationError, match="COUNTROWS supports at most one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_abs(): - expr = _parse_expression("ABS('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "ABS(amount)" - - -def test_translate_abs_requires_one_argument(): - expr = _parse_expression("ABS('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="ABS requires exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_mod(): - expr = _parse_expression("MOD('sales'[amount], 3)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "MOD(amount, 3)" - - -def test_translate_mod_requires_two_arguments(): - expr = _parse_expression("MOD('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="MOD requires number and divisor arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_power(): - expr = _parse_expression("POWER('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "POWER(amount, 2)" - - -def test_translate_power_requires_two_arguments(): - expr = _parse_expression("POWER('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="POWER requires number and exponent arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_sqrt(): - expr = _parse_expression("SQRT('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "SQRT(amount)" - - -def test_translate_exp(): - expr = _parse_expression("EXP('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "EXP(amount)" - - -def test_translate_ln(): - expr = _parse_expression("LN('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "LN(amount)" - - -def test_translate_log10(): - expr = _parse_expression("LOG10('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "LOG10(amount)" - - -def test_translate_log_default_base(): - expr = _parse_expression("LOG('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "LOG10(amount)" - - -def test_translate_log_with_base(): - expr = _parse_expression("LOG('sales'[amount], 2)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "(LN(amount) / LN(2))" - - -def test_translate_log_rejects_more_than_two_arguments(): - expr = _parse_expression("LOG('sales'[amount], 2, 3)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="LOG supports at most number and base arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_pi(): - expr = _parse_expression("PI()") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "PI()" - - -def test_translate_pi_rejects_arguments(): - expr = _parse_expression("PI(1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="PI does not take arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_nameof(): - expr = _parse_expression("NAMEOF('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "'sales[amount]'" - - -def test_translate_nameof_rejects_more_than_one_argument(): - expr = _parse_expression("NAMEOF('sales'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="NAMEOF supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_convert(): - expr = _parse_expression("CONVERT('sales'[amount], STRING)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "CAST(amount AS VARCHAR)" - - -def test_translate_convert_rejects_more_than_two_arguments(): - expr = _parse_expression("CONVERT('sales'[amount], STRING, 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="CONVERT supports exactly value and datatype arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_related_rejects_more_than_one_argument(): - expr = _parse_expression("RELATED('other'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="RELATED supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_value_rejects_more_than_one_argument(): - expr = _parse_expression("VALUE('sales'[amount_text], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="VALUE supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_concatenate_rejects_more_than_two_arguments(): - expr = _parse_expression('CONCATENATE(\'sales\'[sku], "-", "x")') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="CONCATENATE supports exactly two arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_concatenatex(): - expr = _parse_expression("CONCATENATEX('sales', 'sales'[product_key], \"-\", 'sales'[product_key], DESC)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert "STRING_AGG(CAST(product_key AS VARCHAR), CAST('-' AS VARCHAR) ORDER BY product_key DESC)" in translation.sql - assert "FROM sales" in translation.sql - - -def test_translate_concatenatex_wrapped_table_expression(): - expr = _parse_expression("CONCATENATEX(FILTER('sales', 'sales'[amount] > 10), 'sales'[product_key], \",\")") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert "STRING_AGG(CAST(t.product_key AS VARCHAR), CAST(',' AS VARCHAR))" in translation.sql - assert "FROM (SELECT * FROM sales WHERE (amount > 10)) AS t" in translation.sql - - -def test_translate_concatenatex_cross_table_expression_tracks_required_model(): - expr = _parse_expression("CONCATENATEX('sales', 'products'[category], \",\")") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert "STRING_AGG(CAST(products.category AS VARCHAR), CAST(',' AS VARCHAR))" in translation.sql - assert "FROM sales LEFT JOIN products ON sales.product_key = products.product_key" in translation.sql - assert "products" in translation.required_models - - -def test_translate_concatenatex_requires_table_and_expression_arguments(): - expr = _parse_expression("CONCATENATEX('sales')") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="CONCATENATEX requires table and expression arguments"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_if_rejects_more_than_three_arguments(): - expr = _parse_expression("IF(TRUE(), 1, 2, 3)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises( - DaxTranslationError, match="IF supports at most condition, value_if_true, and value_if_false arguments" - ): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_evaluateandlog(): - expr = _parse_expression("EVALUATEANDLOG('sales'[amount])") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "amount" - - -def test_translate_evaluateandlog_rejects_more_than_one_argument(): - expr = _parse_expression("EVALUATEANDLOG('sales'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="EVALUATEANDLOG supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_ignore_rejects_more_than_one_argument(): - expr = _parse_expression("IGNORE('sales'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="IGNORE supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_nonvisual_rejects_more_than_one_argument(): - expr = _parse_expression("NONVISUAL('sales'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="NONVISUAL supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_upper_lower_reject_more_than_one_argument(): - upper_expr = _parse_expression('UPPER("abc", "x")') - lower_expr = _parse_expression('LOWER("ABC", "x")') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - - with pytest.raises(DaxTranslationError, match="UPPER supports exactly one argument"): - translate_dax_metric( - upper_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - with pytest.raises(DaxTranslationError, match="LOWER supports exactly one argument"): - translate_dax_metric( - lower_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_isblank_rejects_more_than_one_argument(): - isblank_expr = _parse_expression("ISBLANK('sales'[amount], 1)") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - - with pytest.raises(DaxTranslationError, match="ISBLANK supports exactly one argument"): - translate_dax_metric( - isblank_expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_isempty_table_expression(): - expr = _parse_expression("ISEMPTY(FILTER('sales', 'sales'[amount] > 0))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.type == "derived" - assert translation.sql == "NOT EXISTS (SELECT 1 FROM (SELECT * FROM sales WHERE (amount > 0)) AS t)" - - -def test_translate_isempty_rejects_more_than_one_argument(): - expr = _parse_expression("ISEMPTY('sales', 'sales')") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="ISEMPTY supports exactly one argument"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_values_identifier_table_ref(): - expr = _parse_expression("VALUES(sales)") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT DISTINCT * FROM sales" - - -@pytest.mark.parametrize( - ("expr_sql", "expected_fragment"), - [ - ("UNION('sales', 'tax')", "UNION ALL"), - ("INTERSECT('sales', 'tax')", "INTERSECT ALL"), - ("EXCEPT('sales', 'tax')", "EXCEPT ALL"), - ("CROSSJOIN('sales', 'tax')", "CROSS JOIN"), - ("NATURALINNERJOIN('sales', 'tax')", "NATURAL INNER JOIN"), - ("NATURALLEFTOUTERJOIN('sales', 'tax')", "NATURAL LEFT JOIN"), - ], -) -def test_translate_table_set_operations_allow_multi_table_refs_when_model_is_none( - expr_sql: str, expected_fragment: str -): - expr = _parse_expression(expr_sql) - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "tax": {"rate": "rate", "date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert expected_fragment in translation.sql - assert "SELECT * FROM sales" in translation.sql - assert "SELECT * FROM tax" in translation.sql - - -def test_translate_generate_allows_cross_table_right_table_when_model_is_none(): - expr = _parse_expression("GENERATE('sales', FILTER('tax', 'tax'[rate] > 0))") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount", "date_key": "date_key"}, - "tax": {"rate": "rate", "date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert "CROSS JOIN LATERAL" in translation.sql - assert "FROM (SELECT * FROM sales) AS l" in translation.sql - assert "FROM tax" in translation.sql - assert "rate > 0" in translation.sql - - -def test_translate_row_table_allows_multi_table_refs_when_model_is_none(): - expr = _parse_expression("ROW(\"sales_amount\", 'sales'[amount], \"tax_rate\", 'tax'[rate])") - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table={ - "sales": {"amount": "amount"}, - "tax": {"rate": "rate"}, - }, - measure_names_by_table={"sales": set(), "tax": set()}, - ) - - assert "SELECT amount AS sales_amount, tax.rate AS tax_rate FROM sales CROSS JOIN tax" in translation.sql - assert "sales" in translation.required_models - assert "tax" in translation.required_models - - -def test_translate_calendar_table(): - expr = _parse_expression('CALENDAR("2024-01-01", "2024-01-03")') - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={"sales": set()}, - ) - - assert translation.sql == ( - "SELECT date_value AS Date FROM generate_series(CAST('2024-01-01' AS DATE), " - "CAST('2024-01-03' AS DATE), INTERVAL '1 day') AS gs(date_value)" - ) - - -def test_translate_calendar_table_cross_table_aggregate_bounds(): - expr = _parse_expression("CALENDAR(MIN('sales'[order_date]), MAX('date'[date_key]))") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"order_date": "order_date"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert "CAST((SELECT MIN(order_date) FROM sales) AS DATE)" in translation.sql - assert "CAST((SELECT MAX(date.date_key) FROM date) AS DATE)" in translation.sql - assert "date" in translation.required_models - - -def test_translate_generateseries_table(): - expr = _parse_expression("GENERATESERIES(1, 5, 2)") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={"sales": set()}, - ) - - assert translation.sql == "SELECT value FROM generate_series(1, 5, 2) AS gs(value)" - - -def test_translate_generateseries_table_cross_table_aggregate_bounds(): - expr = _parse_expression("GENERATESERIES(MIN('sales'[amount]), MAX('date'[date_key]), 1)") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - ) - - assert ( - "generate_series((SELECT MIN(amount) FROM sales), (SELECT MAX(date.date_key) FROM date), 1)" in translation.sql - ) - assert "date" in translation.required_models - - -def test_translate_firstdate_table(): - expr = _parse_expression("FIRSTDATE('sales'[order_date])") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT MIN(order_date) AS value1 FROM sales" - - -def test_translate_startofyear_table(): - expr = _parse_expression("STARTOFYEAR('sales'[order_date])") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT MIN(DATE_TRUNC('year', order_date)) AS value1 FROM sales" - - -def test_translate_filters_table_column(): - expr = _parse_expression("FILTERS('sales'[product_key])") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT DISTINCT product_key FROM sales" - - -def test_translate_all_table_ref(): - expr = _parse_expression("ALL('sales')") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT * FROM sales" - - -def test_translate_all_column_ref(): - expr = _parse_expression("ALL('sales'[product_key])") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT DISTINCT product_key FROM sales" - - -def test_translate_treatas_table(): - expr = _parse_expression("TREATAS({1, 2}, 'sales'[product_key])") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - assert translation.sql == "SELECT DISTINCT product_key AS product_key FROM sales WHERE (product_key IN (1, 2))" - - -def test_translate_treatas_table_cross_table_target_joins_related_table(): - expr = _parse_expression("TREATAS({\"A\"}, 'products'[category])") - translation = translate_dax_table( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ], - ) - - assert ( - translation.sql - == "SELECT DISTINCT products.category AS category FROM products WHERE (products.category IN ('A'))" - ) - assert "products" in translation.required_models - - -def test_translate_treatas_table_rejects_non_column_target_arguments(): - expr = _parse_expression("TREATAS({1, 2}, 1)") - column_sql_by_table, measure_names_by_table, _time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS target arguments must reference table columns"): - translate_dax_table( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - ) - - -def test_translate_treatas_filter_rejects_table_expression_width_mismatch(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "TREATAS(SELECTCOLUMNS('sales', \"k\", 'sales'[product_key], \"q\", 'sales'[quantity]), 'sales'[product_key])" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_table_ref_width_mismatch_when_known(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS('sales', 'sales'[product_key]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_filter_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), TREATAS(FILTER('sales', 'sales'[amount] > 10), 'sales'[product_key]))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_calculatetable_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), TREATAS(CALCULATETABLE('sales', 'sales'[amount] > 10), 'sales'[product_key]))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_values_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "TREATAS(FILTER(VALUES('sales'[product_key]), 'sales'[product_key] > 1), 'sales'[product_key], 'sales'[quantity])" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_union_wrapper_width_mismatch(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS(UNION('sales', 'sales'), 'sales'[product_key]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_crossjoin_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), TREATAS(CROSSJOIN('sales', 'sales'), 'sales'[product_key]))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_generate_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), TREATAS(GENERATE('sales', 'sales'), 'sales'[product_key]))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_naturalinnerjoin_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), TREATAS(NATURALINNERJOIN('sales', 'sales'), 'sales'[product_key]))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_naturalleftouterjoin_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), TREATAS(NATURALLEFTOUTERJOIN('sales', 'sales'), 'sales'[product_key]))" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_renamecolumns_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "TREATAS(RENAMECOLUMNS('sales', 'sales'[product_key], \"k\"), 'sales'[product_key], 'sales'[quantity])" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_removecolumns_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "TREATAS(REMOVECOLUMNS('sales', 'sales'[amount], 'sales'[quantity], 'sales'[order_date]), " - "'sales'[product_key], 'sales'[quantity])" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_treatas_filter_rejects_keepcolumns_wrapper_width_mismatch(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "TREATAS(KEEPCOLUMNS('sales', 'sales'[product_key], 'sales'[quantity]), 'sales'[product_key])" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - with pytest.raises(DaxTranslationError, match="TREATAS table column count must match target column count"): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - -def test_translate_countrows_filters_as_count_distinct(): - expr = _parse_expression("COUNTROWS(FILTERS('sales'[product_key]))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count_distinct" - assert translation.sql == "product_key" - assert translation.filters == [] - - -def test_translate_countrows_values_filtered_table_propagates_filters(): - expr = _parse_expression("COUNTROWS(VALUES(FILTER('sales', 'sales'[product_key] = 1)))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_countrows_distinct_filtered_table_propagates_filters(): - expr = _parse_expression("COUNTROWS(DISTINCT(FILTER('sales', 'sales'[product_key] = 1)))") - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_countrows_datesbetween_table_propagates_filters(): - expr = _parse_expression('COUNTROWS(DATESBETWEEN(\'sales\'[order_date], "2024-01-01", "2024-12-31"))') - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "count" - assert translation.sql is None - assert translation.filters == ["(order_date >= '2024-01-01' AND order_date <= '2024-12-31')"] - - -def test_translate_summarizecolumns_multiple_tables_cross_join(): - expr = _parse_expression("SUMMARIZECOLUMNS('sales'[product_key], 'products'[category])") - column_sql_by_table = { - "sales": {"product_key": "product_key"}, - "products": {"category": "category"}, - } - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table={}, - ) - - assert translation.sql == ( - "SELECT sales.product_key, products.category FROM sales CROSS JOIN products " - "GROUP BY sales.product_key, products.category" - ) - - -def test_translate_dax_query_warns_when_unrelated_tables_are_cross_joined(): - query = _parse_query("EVALUATE SUMMARIZECOLUMNS('sales'[product_key], 'products'[category])") - translation = translate_dax_query( - query, - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"category": "category"}, - }, - measure_names_by_table={}, - ) - - assert translation.warnings == [] - assert len(translation.evaluates) == 1 - assert translation.evaluates[0].warnings == [ - { - "code": "dax_unrelated_cross_join", - "context": "query", - "base_table": "sales", - "table": "products", - "message": ( - "DAX query cross joins unrelated table 'products' with 'sales' because no relationship path is defined" - ), - } - ] - - -def test_translate_summarizecolumns_multiple_tables_with_relationships(): - expr = _parse_expression("SUMMARIZECOLUMNS('products'[category], \"Revenue\", SUM('sales'[amount]))") - column_sql_by_table = { - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key", "category": "category"}, - } - edges = [ - RelationshipEdge( - from_table="sales", - from_column="product_key", - to_table="products", - to_column="product_key", - ) - ] - translation = translate_dax_table( - expr, - model_name=None, - column_sql_by_table=column_sql_by_table, - measure_names_by_table={}, - relationship_edges=edges, - ) - - assert "LEFT JOIN" in translation.sql - assert "products.product_key" in translation.sql - assert "sales.product_key" in translation.sql - assert "GROUP BY products.category" in translation.sql - - -def test_translate_calculate_cross_table_filter(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'products'[category] = \"A\")") - column_sql_by_table = { - "sales": {"amount": "amount"}, - "products": {"category": "category"}, - } - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "products": {"category"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert any("products" in clause for clause in translation.filters) - assert "products" in translation.required_models - - -def test_translate_calculate_table_ref_filter_candidate_same_table(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'sales')") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert translation.required_models == set() - - -def test_translate_calculate_identifier_table_filter_candidate_cross_table(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), date)") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_calculate_values_cross_table_filter_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), VALUES('date'))") - column_sql_by_table = { - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - } - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_calculate_filter_cross_table_base_table_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), FILTER('date', 'date'[date_key] = 20240101))") - column_sql_by_table = { - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - } - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(date.date_key = 20240101)"] - assert "date" in translation.required_models - - -def test_translate_calculate_filter_cross_table_derived_constructor_alias_predicate_uses_exists(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), FILTER({('sales'[amount], 'date'[date_key])}, [value1] > 0))" - ) - column_sql_by_table = { - "sales": {"amount": "amount", "date_key": "date_key"}, - "date": {"date_key": "date_key"}, - } - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - relationship_edges=[ - RelationshipEdge( - from_table="sales", - from_column="date_key", - to_table="date", - to_column="date_key", - ) - ], - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert len(translation.filters) == 1 - assert translation.filters[0].startswith("EXISTS (SELECT 1 FROM (SELECT * FROM (SELECT") - assert "AS value2 FROM sales" in translation.filters[0] - assert "AS t WHERE (value1 > 0)" in translation.filters[0] - assert "date" in translation.required_models - - -def test_translate_calculate_filter_cross_table_derived_selectcolumns_alias_predicate_rewrites_directly(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), FILTER(SELECTCOLUMNS('date', \"x\", 'date'[date_key]), [x] > 20240101))" - ) - column_sql_by_table = { - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - } - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert len(translation.filters) == 1 - assert translation.filters == ["(date.date_key > 20240101)"] - assert "date" in translation.required_models - - -def test_translate_calculate_filter_same_table_derived_addcolumns_alias_predicate_rewrites_directly(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), FILTER(ADDCOLUMNS('sales', \"x\", 'sales'[amount]), [x] > 0))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(amount > 0)"] - - -def test_translate_calculate_filters_cross_table_filter_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), FILTERS('date'[date_key]))") - column_sql_by_table = { - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - } - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_calculate_distinct_cross_table_filter_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DISTINCT('date'[date_key]))") - column_sql_by_table = { - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - } - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_calculate_treatas_filter(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS({1, 2}, 'sales'[product_key]))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key IN (1, 2))"] - - -def test_translate_calculate_values_table_filter_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), VALUES('sales'))") - translation = translate_dax_metric( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert translation.required_models == {"sales"} - - -def test_translate_calculate_values_column_filter_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), VALUES('sales'[product_key]))") - translation = translate_dax_metric( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert translation.required_models == {"sales"} - - -def test_translate_calculate_filters_column_filter_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), FILTERS('sales'[product_key]))") - translation = translate_dax_metric( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert translation.required_models == {"sales"} - - -def test_translate_calculate_distinct_column_filter_candidate(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), DISTINCT('sales'[product_key]))") - translation = translate_dax_metric( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert translation.required_models == {"sales"} - - -def test_translate_calculate_datesbetween_filter(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), DATESBETWEEN('sales'[order_date], \"2024-01-01\", \"2024-12-31\"))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(order_date >= '2024-01-01' AND order_date <= '2024-12-31')"] - - -def test_translate_calculate_datesbetween_filter_open_ended_with_blank(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), DATESBETWEEN('sales'[order_date], BLANK, \"2024-12-31\"))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "order_date": "order_date"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(order_date <= '2024-12-31')"] - - -def test_translate_calculate_datesbetween_filter_cross_table_start_bound(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), DATESBETWEEN('sales'[order_date], 'date'[date_key], BLANK))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "order_date": "order_date"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(order_date >= date.date_key)"] - assert "date" in translation.required_models - - -def test_translate_calculate_treatas_cross_table_filter(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), TREATAS({\"A\"}, 'products'[category]))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "products": {"category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "products": {"category"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(products.category IN ('A'))"] - assert "products" in translation.required_models - - -def test_translate_calculate_nonvisual_treatas_filter(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), NONVISUAL(TREATAS({1}, 'sales'[product_key])))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(product_key IN (1))"] - - -def test_translate_calculate_removefilters_applies_to_inherited_filters(): - expr = _parse_expression( - "CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), REMOVEFILTERS('sales'[product_key]))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == [] - - -def test_translate_calculate_removefilters_cross_table_clears_inherited_cross_table_filter(): - expr = _parse_expression( - "CALCULATE(CALCULATE(SUM('sales'[amount]), FILTER('date', 'date'[date_key] = 1)), REMOVEFILTERS('date'))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_calculate_all_clears_inherited_filters(): - expr = _parse_expression("CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), ALL())") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == [] - - -def test_translate_calculate_all_cross_table_table_arg_tracks_required_model(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), ALL('date'))") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - measure_names_by_table={"sales": set(), "date": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}, "date": {"date_key"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == [] - assert "date" in translation.required_models - - -def test_translate_calculate_allnoblankrow_clears_inherited_filters(): - expr = _parse_expression("CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), ALLNOBLANKROW())") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == [] - - -def test_translate_calculate_allexcept_keeps_selected_columns(): - expr = _parse_expression( - "CALCULATE(" - "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, 'sales'[quantity] = 2), " - "ALLEXCEPT('sales', 'sales'[product_key])" - ")" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key", "quantity": "quantity"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_allselected_column_removes_targeted_filters(): - expr = _parse_expression( - "CALCULATE(" - "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, 'sales'[quantity] = 2), " - "ALLSELECTED('sales'[quantity])" - ")" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key", "quantity": "quantity"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_allcrossfiltered_table_removes_table_filters(): - expr = _parse_expression( - "CALCULATE(" - "CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, 'sales'[quantity] = 2), " - "ALLCROSSFILTERED('sales')" - ")" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key", "quantity": "quantity"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == [] - - -def test_translate_calculate_allselected_no_args_keeps_inherited_filters(): - expr = _parse_expression("CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), ALLSELECTED())") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == ["(product_key = 1)"] - - -def test_translate_calculate_allselected_no_args_clears_current_scope_filters(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1, ALLSELECTED())") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == [] - - -def test_translate_calculate_allexcept_rejects_cross_table_columns(): - expr = _parse_expression("CALCULATE(SUM('sales'[amount]), ALLEXCEPT('sales', 'products'[category]))") - with pytest.raises(DaxTranslationError): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "products": {"category": "category"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_calculate_replaces_inherited_filter_on_same_column(): - expr = _parse_expression( - "CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), 'sales'[product_key] = 2)" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == ["(product_key = 2)"] - - -def test_translate_calculate_keepfilters_preserves_inherited_filter_on_same_column(): - expr = _parse_expression( - "CALCULATE(CALCULATE(SUM('sales'[amount]), 'sales'[product_key] = 1), KEEPFILTERS('sales'[product_key] = 2))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "product_key": "product_key"}}, - measure_names_by_table={"sales": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.filters == ["(product_key = 1)", "(product_key = 2)"] - - -def test_translate_calculate_accumulates_relationship_overrides(): - expr = _parse_expression( - "CALCULATE(" - "CALCULATE(SUM('sales'[amount]), USERELATIONSHIP('sales'[product_key], 'products'[product_key])), " - "CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)" - ")" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert len(translation.relationship_overrides) == 2 - assert translation.relationship_overrides[0].join_type is None - assert translation.relationship_overrides[1].join_type == "inner" - assert translation.relationship_overrides[1].direction == "Both" - - -def test_translate_calculate_crossfilter_none_direction(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), CROSSFILTER('sales'[product_key], 'products'[product_key], NONE))" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert len(translation.relationship_overrides) == 1 - assert translation.relationship_overrides[0].join_type == "left" - assert translation.relationship_overrides[0].direction == "None" - - -def test_translate_calculate_crossfilter_oneway_leftfiltersright_direction(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "CROSSFILTER('products'[product_key], 'sales'[product_key], ONEWAY_LEFTFILTERSRIGHT)" - ")" - ) - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert len(translation.relationship_overrides) == 1 - assert translation.relationship_overrides[0].join_type is None - assert translation.relationship_overrides[0].direction == "OneWay_LeftFiltersRight" - - -def test_translate_calculate_crossfilter_invalid_direction_error(): - expr = _parse_expression( - "CALCULATE(SUM('sales'[amount]), CROSSFILTER('sales'[product_key], 'products'[product_key], SIDEWAYS))" - ) - with pytest.raises(DaxTranslationError): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - measure_names_by_table={"sales": set(), "products": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_cross_table_metric_tracks_required_model(): - expr = _parse_expression("SUM('other'[amount])") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount", "order_date": "order_date"}, - "other": {"amount": "amount"}, - }, - measure_names_by_table={"sales": {"Total Sales"}, "other": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - assert translation.agg == "sum" - assert translation.sql == "other.amount" - assert "other" in translation.required_models - - -def test_translate_model_scoped_derived_cross_table_metric_tracks_required_model(): - expr = _parse_expression("'sales'[amount] + 'other'[amount]") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "other": {"amount": "amount"}, - }, - measure_names_by_table={"sales": set(), "other": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={}, - ) - - assert translation.type == "derived" - assert translation.sql == "(amount + other.amount)" - assert "other" in translation.required_models - - -def test_translate_model_scoped_var_derived_cross_table_metric_tracks_required_model(): - expr = _parse_expression("VAR x = 'other'[amount] RETURN x + 'sales'[amount]") - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "other": {"amount": "amount"}, - }, - measure_names_by_table={"sales": set(), "other": set()}, - measure_aggs_by_table={}, - time_dimensions_by_table={}, - ) - - assert translation.type == "derived" - assert translation.sql == "(other.amount + amount)" - assert "other" in translation.required_models - - -def test_time_intelligence_requires_known_time_dimension(): - expr = _parse_expression("CALCULATE([Total Sales], SAMEPERIODLASTYEAR('sales'[event_ts]))") - with pytest.raises(DaxTranslationError): - translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount", "event_ts": "event_ts"}}, - measure_names_by_table={"sales": {"Total Sales"}}, - measure_aggs_by_table={}, - time_dimensions_by_table={"sales": {"order_date"}}, - ) - - -def test_translate_table_rejects_scalar_expression_type(): - expr = _parse_expression("1") - with pytest.raises(DaxTranslationError, match="Unsupported table expression type 'Number'"): - translate_dax_table( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={}, - ) - - -def test_translate_table_rejects_unknown_identifier_as_table(): - expr = _parse_expression("UnknownTable") - with pytest.raises(DaxTranslationError, match="Unknown table identifier 'UnknownTable'"): - translate_dax_table( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={}, - ) - - -def test_translate_scalar_rejects_query_expression_node_type(): - expr = _parse_query("EVALUATE 'sales'") - with pytest.raises(DaxTranslationError, match="Unsupported DAX expression type 'Query'"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - ) - - -def test_translate_scalar_unknown_function_error_is_explicit(): - expr = _parse_expression("UNKNOWNFUNC(1, 2)") - with pytest.raises(DaxTranslationError, match="Unsupported scalar function 'UNKNOWNFUNC'"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - ) - - -def test_translate_scalar_calc_group_function_error_is_explicit(): - expr = _parse_expression("SELECTEDMEASURE()") - with pytest.raises(DaxTranslationError, match="SELECTEDMEASURE is only supported in calculation group expressions"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - ) - - -def test_translate_table_detailrows_function_error_is_explicit(): - expr = _parse_expression("DETAILROWS('sales')") - with pytest.raises(DaxTranslationError, match="DETAILROWS is only supported in model detail rows expressions"): - translate_dax_table( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={}, - ) - - -def test_translate_scalar_substitutewithindex_table_function_error_is_explicit(): - expr = _parse_expression("SUBSTITUTEWITHINDEX('sales', \"Idx\", 'sales'[amount], 'sales')") - with pytest.raises( - DaxTranslationError, match="SUBSTITUTEWITHINDEX returns a table and is not valid in scalar context" - ): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - ) - - -def test_translate_calculate_substitutewithindex_filter_propagates_underlying_filters(): - expr = _parse_expression( - "CALCULATE(" - "SUM('sales'[amount]), " - "SUBSTITUTEWITHINDEX(" - "FILTER('sales', 'sales'[amount] > 100), " - '"Idx", ' - "VALUES('sales'[product_key]), " - "'sales'[product_key]" - ")" - ")" - ) - column_sql_by_table, measure_names_by_table, time_dimensions_by_table = _sales_maps() - translation = translate_dax_metric( - expr, - model_name="sales", - column_sql_by_table=column_sql_by_table, - measure_names_by_table=measure_names_by_table, - measure_aggs_by_table={}, - time_dimensions_by_table=time_dimensions_by_table, - ) - - assert translation.agg == "sum" - assert translation.sql == "amount" - assert translation.filters == ["(amount > 100)"] - - -def test_translate_table_calc_group_function_error_is_explicit(): - expr = _parse_expression("SELECTEDMEASURE()") - with pytest.raises(DaxTranslationError, match="SELECTEDMEASURE is only supported in calculation group expressions"): - translate_dax_table( - expr, - model_name=None, - column_sql_by_table={"sales": {"amount": "amount"}}, - measure_names_by_table={}, - ) - - -def test_translate_scalar_table_function_error_is_explicit(): - expr = _parse_expression("INTERSECT({1, 2}, {2, 3})") - with pytest.raises(DaxTranslationError, match="INTERSECT returns a table and is not valid in scalar context"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"amount": "amount"}}, - ) - - -def test_translate_scalar_calculate_filter_only_function_error_is_explicit(): - expr = _parse_expression("USERELATIONSHIP('sales'[product_key], 'products'[product_key])") - with pytest.raises(DaxTranslationError, match="USERELATIONSHIP is only valid in CALCULATE filter arguments"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - ) - - -def test_translate_scalar_crossfilter_calculate_filter_only_function_error_is_explicit(): - expr = _parse_expression("CROSSFILTER('sales'[product_key], 'products'[product_key], BOTH)") - with pytest.raises(DaxTranslationError, match="CROSSFILTER is only valid in CALCULATE filter arguments"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"product_key": "product_key"}, - "products": {"product_key": "product_key"}, - }, - ) - - -def test_translate_scalar_previousweek_table_function_error_is_explicit(): - expr = _parse_expression("PREVIOUSWEEK('date'[date_key])") - with pytest.raises(DaxTranslationError, match="PREVIOUSWEEK returns a table and is not valid in scalar context"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - time_dimensions_by_table={"date": {"date_key"}}, - ) - - -def test_translate_scalar_nextweek_table_function_error_is_explicit(): - expr = _parse_expression("NEXTWEEK('date'[date_key])") - with pytest.raises(DaxTranslationError, match="NEXTWEEK returns a table and is not valid in scalar context"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={ - "sales": {"amount": "amount"}, - "date": {"date_key": "date_key"}, - }, - time_dimensions_by_table={"date": {"date_key"}}, - ) - - -def test_translate_scalar_rollup_wrapper_table_function_error_is_explicit(): - expr = _parse_expression("ROLLUP('sales'[product_key])") - with pytest.raises(DaxTranslationError, match="ROLLUP returns a table and is not valid in scalar context"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"product_key": "product_key"}}, - ) - - -def test_translate_scalar_keepcolumns_table_function_error_is_explicit(): - expr = _parse_expression("KEEPCOLUMNS('sales', 'sales'[product_key])") - with pytest.raises(DaxTranslationError, match="KEEPCOLUMNS returns a table and is not valid in scalar context"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"product_key": "product_key"}}, - ) - - -@pytest.mark.parametrize( - ("expr_text", "func_name"), - [ - ("ROLLUPGROUP('sales'[product_key])", "ROLLUPGROUP"), - ("ROLLUPADDISSUBTOTAL(\"IsTotal\", 'sales'[product_key])", "ROLLUPADDISSUBTOTAL"), - ("ROLLUPISSUBTOTAL(\"IsTotal\", 'sales'[product_key])", "ROLLUPISSUBTOTAL"), - ], -) -def test_translate_scalar_rollup_wrapper_table_functions_error_is_explicit(expr_text: str, func_name: str): - expr = _parse_expression(expr_text) - with pytest.raises(DaxTranslationError, match=rf"{func_name} returns a table and is not valid in scalar context"): - translate_dax_scalar( - expr, - model_name="sales", - column_sql_by_table={"sales": {"product_key": "product_key"}}, - ) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 86b021f4..4e61c209 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1,6 +1,5 @@ """Tests for CLI command wiring.""" -import textwrap from pathlib import Path import duckdb @@ -10,7 +9,6 @@ import sidemantic.cli as cli_module from sidemantic.cli import app from sidemantic.config import SidemanticConfig -from sidemantic.loaders import load_from_directory as load_models_from_directory from tests.optional_dep_stubs import ensure_fake_mcp, ensure_fake_riffq runner = CliRunner() @@ -35,34 +33,6 @@ def _write_min_model(directory: Path) -> None: ) -def _write_min_tmdl_model(path: Path) -> None: - path.write_text( - textwrap.dedent( - """ - table Sales - column SaleID - dataType: int64 - isKey - sourceColumn: SaleID - column Amount - dataType: decimal - sourceColumn: Amount - measure Revenue = SUM(Sales[Amount]) - """ - ) - ) - - -def _require_sidemantic_dax_native(): - sidemantic_dax = pytest.importorskip("sidemantic_dax") - try: - sidemantic_dax.parse_expression("1") - except RuntimeError as exc: - if "native module is not available" in str(exc): - pytest.skip("sidemantic_dax native module not available") - raise - - def _write_orders_db(path: Path) -> None: conn = duckdb.connect(str(path)) conn.execute("CREATE TABLE orders (id INTEGER, status VARCHAR)") @@ -370,234 +340,6 @@ def fake_run(*args, **kwargs): assert called.get("run") is True -def test_dax_query_dry_run_outputs_translated_sql(monkeypatch, tmp_path): - monkeypatch.setattr( - "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", - lambda self, dax, evaluate=1: {"sql": "SELECT 1 AS one", "warnings": [], "import_warnings": []}, - ) - - _write_min_model(tmp_path) - result = runner.invoke( - app, - [ - "dax-query", - 'EVALUATE ROW("one", 1)', - "--models", - str(tmp_path), - "--dry-run", - ], - ) - - assert result.exit_code == 0 - assert "SELECT 1 AS one" in result.stdout - - -def test_dax_query_dry_run_uses_real_dax_parser_and_translator(tmp_path): - _require_sidemantic_dax_native() - - _write_min_model(tmp_path) - result = runner.invoke( - app, - [ - "dax-query", - """EVALUATE SUMMARIZECOLUMNS('orders'[status], "Orders", [order_count])""", - "--models", - str(tmp_path), - "--dry-run", - ], - ) - - assert result.exit_code == 0 - assert result.stdout == "SELECT orders.status, COUNT(*) AS Orders FROM orders GROUP BY orders.status\n" - - -def test_dax_query_dry_run_loads_standalone_tmdl_file(tmp_path): - _require_sidemantic_dax_native() - - tmdl_file = tmp_path / "Sales.tmdl" - _write_min_tmdl_model(tmdl_file) - - result = runner.invoke( - app, - [ - "dax-query", - 'EVALUATE ROW("Revenue", [Revenue])', - "--models", - str(tmdl_file), - "--dry-run", - ], - ) - - assert result.exit_code == 0 - assert result.stdout == "SELECT SUM(Sales.Amount) AS Revenue FROM Sales\n" - - -def test_dax_query_executes_real_dax_against_duckdb_file(tmp_path): - _require_sidemantic_dax_native() - duckdb = pytest.importorskip("duckdb") - - _write_min_model(tmp_path) - db_path = tmp_path / "orders.duckdb" - conn = duckdb.connect(str(db_path)) - try: - conn.execute("CREATE TABLE orders (id INTEGER, status VARCHAR)") - conn.execute("INSERT INTO orders VALUES (1, 'open'), (2, 'open'), (3, 'closed')") - finally: - conn.close() - - result = runner.invoke( - app, - [ - "dax-query", - """EVALUATE SUMMARIZECOLUMNS('orders'[status], "Orders", [order_count]) """ - "ORDER BY 'orders'[status] ASC", - "--models", - str(tmp_path), - "--db", - str(db_path), - ], - ) - - assert result.exit_code == 0 - assert result.stdout == "status,Orders\nclosed,1\nopen,2\n" - - -def test_dax_query_executes_through_database_adapter(monkeypatch, tmp_path): - monkeypatch.setattr( - "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", - lambda self, dax, evaluate=1: {"sql": "SELECT 1 AS one", "warnings": [], "import_warnings": []}, - ) - executed = [] - - class FakeResult: - description = [("one",)] - - def fetchall(self): - return [(1,)] - - def _execute(self, sql): - executed.append(sql) - return FakeResult() - - monkeypatch.setattr("sidemantic.db.duckdb.DuckDBAdapter.execute", _execute) - - _write_min_model(tmp_path) - result = runner.invoke( - app, - [ - "dax-query", - 'EVALUATE ROW("one", 1)', - "--models", - str(tmp_path), - ], - ) - - assert result.exit_code == 0 - assert executed == ["SELECT 1 AS one"] - assert "one" in result.stdout - assert "1" in result.stdout - - -def test_dax_query_evaluate_index_out_of_range(monkeypatch, tmp_path): - def _raise_out_of_range(self, dax, evaluate=1): - raise ValueError("evaluate index 2 is out of range; query has 1 EVALUATE statement(s)") - - monkeypatch.setattr("sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", _raise_out_of_range) - - _write_min_model(tmp_path) - result = runner.invoke( - app, - [ - "dax-query", - 'EVALUATE ROW("one", 1)', - "--models", - str(tmp_path), - "--evaluate", - "2", - ], - ) - - assert result.exit_code == 1 - assert "out of range" in result.stderr - - -def test_dax_query_emits_import_warnings(monkeypatch, tmp_path): - monkeypatch.setattr( - "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", - lambda self, dax, evaluate=1: {"sql": "SELECT 1 AS one", "warnings": [], "import_warnings": []}, - ) - - def _load_with_warning(layer, directory): - load_models_from_directory(layer, directory) - layer.graph.import_warnings = [ - { - "code": "dax_translation_fallback", - "context": "measure", - "name": "Revenue", - "message": "Unsupported table expression", - "file": "definition/tables/Sales.tmdl", - "line": 12, - "column": 8, - } - ] - - monkeypatch.setattr("sidemantic.cli.load_from_directory", _load_with_warning) - - _write_min_model(tmp_path) - result = runner.invoke( - app, - [ - "dax-query", - 'EVALUATE ROW("one", 1)', - "--models", - str(tmp_path), - "--dry-run", - ], - ) - - assert result.exit_code == 0 - assert "SELECT 1 AS one" in result.stdout - assert "import warning(s)" in result.stderr - assert "dax_translation_fallback" in result.stderr - - -def test_dax_query_emits_translation_warnings(monkeypatch, tmp_path): - monkeypatch.setattr( - "sidemantic.core.semantic_layer.SemanticLayer.compile_dax_query_payload", - lambda self, dax, evaluate=1: { - "sql": "SELECT 1 AS one", - "warnings": [ - { - "code": "dax_unrelated_cross_join", - "context": "query", - "base_table": "sales", - "table": "products", - "message": "DAX query cross joins unrelated table 'products' with 'sales'", - } - ], - "import_warnings": [], - }, - ) - - _write_min_model(tmp_path) - result = runner.invoke( - app, - [ - "dax-query", - 'EVALUATE ROW("one", 1)', - "--models", - str(tmp_path), - "--dry-run", - ], - ) - - assert result.exit_code == 0 - assert "SELECT 1 AS one" in result.stdout - assert "DAX query warning(s)" in result.stderr - assert "dax_unrelated_cross_join" in result.stderr - assert "base_table=sales" in result.stderr - - def test_docker_entrypoint_does_not_use_eval(): entrypoint_path = Path(__file__).resolve().parent.parent / "docker-entrypoint.sh" content = entrypoint_path.read_text() diff --git a/tests/test_relationship_override_sql.py b/tests/test_relationship_override_sql.py deleted file mode 100644 index 947a6055..00000000 --- a/tests/test_relationship_override_sql.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Regression tests for metric-local relationship overrides in SQL generation.""" - -from sidemantic.core.dimension import Dimension -from sidemantic.core.metric import Metric -from sidemantic.core.model import Model -from sidemantic.core.relationship import Relationship, RelationshipOverride -from sidemantic.core.semantic_graph import SemanticGraph -from sidemantic.sql.generator import SQLGenerator - - -def test_metric_relationship_override_replaces_default_join_path(): - graph = SemanticGraph() - graph.add_model( - Model( - name="Sales", - table="sales", - primary_key="SalesKey", - relationships=[ - Relationship( - name="Calendar", - type="many_to_one", - foreign_key="OrderDateKey", - primary_key="DateKey", - ), - Relationship( - name="Calendar", - type="many_to_one", - foreign_key="ShipDateKey", - primary_key="DateKey", - active=False, - ), - ], - metrics=[ - Metric( - name="Ship Sales", - agg="sum", - sql="Amount", - required_models=["Calendar"], - relationship_overrides=[ - RelationshipOverride( - from_model="Sales", - from_column="ShipDateKey", - to_model="Calendar", - to_column="DateKey", - ) - ], - ) - ], - ) - ) - graph.add_model( - Model( - name="Calendar", - table="calendar", - primary_key="DateKey", - dimensions=[Dimension(name="Date", type="time", sql="Date")], - ) - ) - - sql = SQLGenerator(graph).generate(metrics=["Sales.Ship Sales"], dimensions=["Calendar.Date"]) - - assert "ShipDateKey" in sql - assert "DateKey = Sales_cte.ShipDateKey" in sql - assert "OrderDateKey = Calendar_cte.DateKey" not in sql - assert "Calendar_cte.DateKey = Sales_cte.OrderDateKey" not in sql - - -def test_crossfilter_join_type_override_is_used_for_join(): - graph = SemanticGraph() - graph.add_model( - Model( - name="Sales", - table="sales", - primary_key="SalesKey", - relationships=[ - Relationship( - name="Products", - type="many_to_one", - foreign_key="ProductKey", - primary_key="ProductKey", - ) - ], - metrics=[ - Metric( - name="Product Sales", - agg="sum", - sql="Amount", - required_models=["Products"], - relationship_overrides=[ - RelationshipOverride( - from_model="Sales", - from_column="ProductKey", - to_model="Products", - to_column="ProductKey", - join_type="inner", - direction="Both", - ) - ], - ) - ], - ) - ) - graph.add_model( - Model( - name="Products", - table="products", - primary_key="ProductKey", - dimensions=[Dimension(name="Category", type="categorical", sql="Category")], - ) - ) - - sql = SQLGenerator(graph).generate(metrics=["Sales.Product Sales"], dimensions=["Products.Category"]) - - assert "INNER JOIN" in sql diff --git a/tests/test_semantic_graph_errors.py b/tests/test_semantic_graph_errors.py index c026742f..10d41c0f 100644 --- a/tests/test_semantic_graph_errors.py +++ b/tests/test_semantic_graph_errors.py @@ -259,51 +259,3 @@ def test_inactive_relationship_is_not_used_for_default_path(): with pytest.raises(ValueError, match="No join path found"): graph.find_relationship_path("sales", "calendar") - - -def test_relationship_override_can_activate_query_local_path(): - """Metric-local overrides provide a join path without reactivating the graph edge.""" - from sidemantic.core.relationship import Relationship, RelationshipOverride - - graph = SemanticGraph() - sales = Model( - name="sales", - table="sales", - primary_key="id", - relationships=[ - Relationship( - name="calendar", - type="many_to_one", - foreign_key="order_date_key", - primary_key="date_key", - ), - Relationship( - name="calendar", - type="many_to_one", - foreign_key="ship_date_key", - primary_key="date_key", - active=False, - ), - ], - ) - calendar = Model(name="calendar", table="calendar", primary_key="date_key") - - graph.add_model(sales) - graph.add_model(calendar) - - default_path = graph.find_relationship_path("sales", "calendar") - override_path = graph.find_relationship_path( - "sales", - "calendar", - [ - RelationshipOverride( - from_model="sales", - from_column="ship_date_key", - to_model="calendar", - to_column="date_key", - ) - ], - ) - - assert [(hop.from_columns, hop.to_columns) for hop in default_path] == [(["order_date_key"], ["date_key"])] - assert [(hop.from_columns, hop.to_columns) for hop in override_path] == [(["ship_date_key"], ["date_key"])] diff --git a/tests/test_sidemantic_adapter_metadata.py b/tests/test_sidemantic_adapter_metadata.py deleted file mode 100644 index e6038ebc..00000000 --- a/tests/test_sidemantic_adapter_metadata.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for native Sidemantic YAML relationship metadata.""" - -import yaml - -from sidemantic.adapters.sidemantic import SidemanticAdapter -from sidemantic.core.introspection import describe_graph - - -def test_native_adapter_round_trips_relationship_activity_and_metric_overrides(tmp_path): - source = tmp_path / "models.yml" - source.write_text( - """ -models: - - name: Sales - table: sales - primary_key: SalesKey - relationships: - - name: Calendar - type: many_to_one - foreign_key: ShipDateKey - primary_key: DateKey - active: false - metrics: - - name: Ship Sales - agg: sum - sql: Amount - required_models: - - Calendar - relationship_overrides: - - from_model: Sales - from_column: ShipDateKey - to_model: Calendar - to_column: DateKey - join_type: inner - direction: Both - - name: Calendar - table: calendar - primary_key: DateKey -""" - ) - - graph = SidemanticAdapter(lower_dax=False).parse(source) - sales = graph.models["Sales"] - relationship = sales.relationships[0] - metric = sales.get_metric("Ship Sales") - - assert relationship.active is False - assert metric.required_models == ["Calendar"] - assert len(metric.relationship_overrides) == 1 - assert metric.relationship_overrides[0].from_column == "ShipDateKey" - - description = describe_graph(graph, model_names=["Sales"]) - metric_info = description["models"][0]["metrics"][0] - assert metric_info["relationship_overrides"] == [ - { - "from_model": "Sales", - "from_column": "ShipDateKey", - "to_model": "Calendar", - "to_column": "DateKey", - "join_type": "inner", - "direction": "Both", - } - ] - - exported_path = tmp_path / "exported.yml" - SidemanticAdapter(lower_dax=False).export(graph, exported_path) - exported = yaml.safe_load(exported_path.read_text()) - - exported_sales = exported["models"][0] - assert exported_sales["relationships"][0]["active"] is False - exported_metric = exported_sales["metrics"][0] - assert exported_metric["required_models"] == ["Calendar"] - assert exported_metric["relationship_overrides"][0]["from_column"] == "ShipDateKey" diff --git a/tests/test_validation.py b/tests/test_validation.py index 4ab4454e..b1109f0f 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -26,7 +26,7 @@ def test_model_has_default_primary_key(layer): def test_model_validation_no_table(layer): - """Test that models without table or sql are rejected.""" + """Test that models without table, sql, or dax are rejected.""" invalid_model = Model( name="orders", primary_key="id", @@ -37,7 +37,7 @@ def test_model_validation_no_table(layer): with pytest.raises(ModelValidationError) as exc_info: layer.add_model(invalid_model) - assert "must have either 'table' or 'sql' defined" in str(exc_info.value) + assert "must have 'table', 'sql', or 'dax' defined" in str(exc_info.value) def test_metric_validation_simple_no_measure(): From 534c07ec963d5ab8ae0e48ca205df2128f8b2379 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 May 2026 14:50:56 +0000 Subject: [PATCH 4/9] Auto-update JSON schema --- sidemantic-schema.json | 6181 +++++++++++++++++++++------------------- 1 file changed, 3240 insertions(+), 2941 deletions(-) diff --git a/sidemantic-schema.json b/sidemantic-schema.json index 6801071e..a75bf879 100644 --- a/sidemantic-schema.json +++ b/sidemantic-schema.json @@ -1,1283 +1,1679 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Sidemantic Semantic Layer", - "description": "Schema for Sidemantic semantic layer YAML configuration", - "type": "object", - "properties": { - "models": { - "type": "array", - "description": "Model definitions", - "items": { - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "name": { - "description": "Unique dimension name within model", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Dimension type", - "enum": [ - "categorical", - "time", - "boolean", - "numeric" - ], - "title": "Type", + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique dimension name within model", + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" + }, + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" + }, + "supported_granularities": { + "anyOf": [ + { + "items": { "type": "string" }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" - }, - "supported_granularities": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "parent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" - } + "type": "array" }, - "required": [ - "name", - "type" - ], - "title": "Dimension", - "type": "object" - }, - "Index": { - "description": "Index definition for pre-aggregation performance.", - "properties": { - "name": { - "description": "Index name", - "title": "Name", - "type": "string" - }, - "columns": { - "description": "Columns to index", - "items": { - "type": "string" - }, - "title": "Columns", - "type": "array" - }, - "type": { - "default": "regular", - "description": "Index type", - "enum": [ - "regular", - "aggregate" - ], - "title": "Type", - "type": "string" - } + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" + }, + "type": { + "description": "Dimension type", + "enum": [ + "categorical", + "time", + "boolean", + "numeric" + ], + "title": "Type", + "type": "string" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "name", - "columns" - ], - "title": "Index", - "type": "object" + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" + } + }, + "required": [ + "name", + "type" + ], + "title": "Dimension", + "type": "object" + }, + "Index": { + "description": "Index definition for pre-aggregation performance.", + "properties": { + "columns": { + "description": "Columns to index", + "items": { + "type": "string" }, - "Metric": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text", - "title": "Dax" + "title": "Columns", + "type": "array" + }, + "name": { + "description": "Index name", + "title": "Name", + "type": "string" + }, + "type": { + "default": "regular", + "description": "Index type", + "enum": [ + "regular", + "aggregate" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "columns" + ], + "title": "Index", + "type": "object" + }, + "Metric": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "base_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "cohort_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" + }, + "entity": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "filters": { + "anyOf": [ + { + "items": { + "type": "string" }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" + }, + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" - }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" + }, + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "steps": { + "anyOf": [ + { + "items": { + "type": "string" }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "base_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "window_expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" + }, + "window_frame": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" + }, + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "Parameter": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" + }, + "PreAggregation": { + "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", + "properties": { + "build_range_end": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for end of data range to aggregate", + "title": "Build Range End" + }, + "build_range_start": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for start of data range to aggregate", + "title": "Build Range Start" + }, + "dimensions": { + "anyOf": [ + { + "items": { + "type": "string" }, - "conversion_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to group by (e.g., ['status', 'region'])", + "title": "Dimensions" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for aggregation", + "title": "Granularity" + }, + "indexes": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Index" }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Index definitions for query performance", + "title": "Indexes" + }, + "measures": { + "anyOf": [ + { + "items": { + "type": "string" }, - "cohort_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", + "title": "Measures" + }, + "name": { + "description": "Unique pre-aggregation name", + "title": "Name", + "type": "string" + }, + "partition_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Partition size for incremental refresh", + "title": "Partition Granularity" + }, + "refresh_key": { + "anyOf": [ + { + "$ref": "#/$defs/RefreshKey" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh strategy configuration" + }, + "scheduled_refresh": { + "default": true, + "description": "Whether to enable scheduled refresh", + "title": "Scheduled Refresh", + "type": "boolean" + }, + "time_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time dimension for temporal grouping", + "title": "Time Dimension" + }, + "type": { + "default": "rollup", + "description": "Pre-aggregation type", + "enum": [ + "rollup", + "original_sql", + "rollup_join", + "lambda" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PreAggregation", + "type": "object" + }, + "RefreshKey": { + "description": "Refresh strategy configuration for pre-aggregations.", + "properties": { + "every": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", + "title": "Every" + }, + "incremental": { + "default": false, + "description": "Whether to use incremental refresh (only update changed partitions)", + "title": "Incremental", + "type": "boolean" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL query that returns a value to trigger refresh when changed", + "title": "Sql" + }, + "update_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", + "title": "Update Window" + } + }, + "title": "RefreshKey", + "type": "object" + }, + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", + "properties": { + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" + }, + "foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" }, - "activity_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Name of the related model", + "title": "Name", + "type": "string" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" + }, + "related_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" + }, + "through": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Junction model for many_to_many relationships", + "title": "Through" + }, + "through_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" + }, + "type": { + "description": "Type of relationship", + "enum": [ + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "Segment": { + "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "name": { + "description": "Unique segment name", + "title": "Name", + "type": "string" + }, + "public": { + "default": true, + "description": "Whether segment is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "sql": { + "description": "SQL WHERE clause expression", + "title": "Sql", + "type": "string" + } + }, + "required": [ + "name", + "sql" + ], + "title": "Segment", + "type": "object" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Schema for Sidemantic semantic layer YAML configuration", + "properties": { + "metrics": { + "description": "Top-level metric definitions (optional - can also define in models)", + "items": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "activity_event": { + "anyOf": [ + { + "type": "string" }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" + }, + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" + "type": "string" }, - "entity_dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" + }, + "base_event": { + "anyOf": [ + { + "type": "string" }, - "having": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" + }, + "base_metric": { + "anyOf": [ + { + "type": "string" }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" + }, + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio" ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" + "type": "string" }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" + }, + "cohort_event": { + "anyOf": [ + { + "type": "string" }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" + }, + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" ], - "default": null, - "description": "Display label", - "title": "Label" + "type": "string" }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" + }, + "conversion_event": { + "anyOf": [ + { + "type": "string" }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" + }, + "conversion_window": { + "anyOf": [ + { + "type": "string" }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" + }, + "dax": { + "anyOf": [ + { + "type": "string" }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, + "denominator": { + "anyOf": [ + { + "type": "string" }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" + { + "type": "null" } - }, - "required": [ - "name" ], - "title": "Metric", - "type": "object" + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" }, - "PreAggregation": { - "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", - "properties": { - "name": { - "description": "Unique pre-aggregation name", - "title": "Name", + "entity": { + "anyOf": [ + { "type": "string" }, - "type": { - "default": "rollup", - "description": "Pre-aggregation type", - "enum": [ - "rollup", - "original_sql", - "rollup_join", - "lambda" - ], - "title": "Type", - "type": "string" - }, - "measures": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", - "title": "Measures" - }, - "dimensions": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to group by (e.g., ['status', 'region'])", - "title": "Dimensions" + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "time_dimension": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" ], - "default": null, - "description": "Time dimension for temporal grouping", - "title": "Time Dimension" + "type": "string" }, - "granularity": { - "anyOf": [ - { - "enum": [ - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for aggregation", - "title": "Granularity" + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "extends": { + "anyOf": [ + { + "type": "string" }, - "partition_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Partition size for incremental refresh", - "title": "Partition Granularity" + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" }, - "refresh_key": { - "anyOf": [ - { - "$ref": "#/$defs/RefreshKey" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Refresh strategy configuration" + { + "type": "number" }, - "scheduled_refresh": { - "default": true, - "description": "Whether to enable scheduled refresh", - "title": "Scheduled Refresh", - "type": "boolean" + { + "type": "string" }, - "indexes": { - "anyOf": [ - { - "items": { - "$ref": "#/$defs/Index" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Index definitions for query performance", - "title": "Indexes" + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" }, - "build_range_start": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for start of data range to aggregate", - "title": "Build Range Start" + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "format": { + "anyOf": [ + { + "type": "string" }, - "build_range_end": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression for end of data range to aggregate", - "title": "Build Range End" + { + "type": "null" } - }, - "required": [ - "name" ], - "title": "PreAggregation", - "type": "object" + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" }, - "RefreshKey": { - "description": "Refresh strategy configuration for pre-aggregations.", - "properties": { - "every": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" ], - "default": null, - "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", - "title": "Every" + "type": "string" }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL query that returns a value to trigger refresh when changed", - "title": "Sql" - }, - "incremental": { - "default": false, - "description": "Whether to use incremental refresh (only update changed partitions)", - "title": "Incremental", - "type": "boolean" - }, - "update_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", - "title": "Update Window" - } - }, - "title": "RefreshKey", - "type": "object" - }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "name": { - "description": "Name of the related model", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Type of relationship", - "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" - ], - "title": "Type", - "type": "string" - }, - "foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" - }, - "primary_key": { - "anyOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "active": { - "default": true, - "description": "Whether the relationship is active by default", - "title": "Active", - "type": "boolean" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - } - }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" - }, - "Segment": { - "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", - "properties": { - "name": { - "description": "Unique segment name", - "title": "Name", - "type": "string" - }, - "sql": { - "description": "SQL WHERE clause expression", - "title": "Sql", - "type": "string" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "public": { - "default": true, - "description": "Whether segment is visible in API/UI", - "title": "Public", - "type": "boolean" + { + "type": "null" } - }, - "required": [ - "name", - "sql" ], - "title": "Segment", - "type": "object" - } - }, - "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique model name", - "title": "Name", - "type": "string" + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" }, - "table": { + "having": { "anyOf": [ { "type": "string" @@ -1287,23 +1683,27 @@ } ], "default": null, - "description": "Physical table name (schema.table)", - "title": "Table" + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" }, - "sql": { + "inner_metrics": { "anyOf": [ { - "type": "string" + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "SQL expression for derived tables", - "title": "Sql" + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" }, - "dax": { + "label": { "anyOf": [ { "type": "string" @@ -1313,53 +1713,43 @@ } ], "default": null, - "description": "DAX table expression source text", - "title": "Dax" + "description": "Display label", + "title": "Label" }, - "expression_language": { + "meta": { "anyOf": [ { - "enum": [ - "sql", - "dax" - ], - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], "default": null, - "description": "Expression language for sql/dax derived table authoring", - "title": "Expression Language" + "description": "Arbitrary metadata for extensions", + "title": "Meta" }, - "source_uri": { + "metadata": { "anyOf": [ { - "type": "string" + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], "default": null, - "description": "Remote data source URI (e.g., https://, s3://, gs://)", - "title": "Source Uri" + "description": "Adapter-specific metadata payload", + "title": "Metadata" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" }, - "extends": { + "non_additive_dimension": { "anyOf": [ { "type": "string" @@ -1369,99 +1759,23 @@ } ], "default": null, - "description": "Parent model to inherit from", - "title": "Extends" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "relationships": { - "description": "Relationships to other models", - "items": { - "$ref": "#/$defs/Relationship" - }, - "title": "Relationships", - "type": "array" + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" }, - "primary_key": { + "numerator": { "anyOf": [ { "type": "string" }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "id", - "description": "Primary key column(s)", - "title": "Primary Key" - }, - "unique_keys": { - "anyOf": [ - { - "items": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": "array" - }, { "type": "null" } ], "default": null, - "description": "Unique key constraints (each is a list of columns)", - "title": "Unique Keys" - }, - "dimensions": { - "description": "Dimension definitions", - "items": { - "$ref": "#/$defs/Dimension" - }, - "title": "Dimensions", - "type": "array" - }, - "metrics": { - "description": "Measure definitions", - "items": { - "$ref": "#/$defs/Metric" - }, - "title": "Metrics", - "type": "array" - }, - "segments": { - "description": "Segment (named filter) definitions", - "items": { - "$ref": "#/$defs/Segment" - }, - "title": "Segments", - "type": "array" - }, - "pre_aggregations": { - "description": "Pre-aggregation definitions for query optimization", - "items": { - "$ref": "#/$defs/PreAggregation" - }, - "title": "Pre Aggregations", - "type": "array" + "description": "Numerator measure for ratio", + "title": "Numerator" }, - "default_time_dimension": { + "offset_window": { "anyOf": [ { "type": "string" @@ -1471,99 +1785,35 @@ } ], "default": null, - "description": "Default time dimension for metrics (auto-included in queries)", - "title": "Default Time Dimension" + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" }, - "default_grain": { + "periods": { "anyOf": [ { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" + "type": "integer" }, { "type": "null" } ], "default": null, - "description": "Default time granularity when using default_time_dimension", - "title": "Default Grain" + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" }, - "auto_dimensions": { - "default": false, - "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", - "title": "Auto Dimensions", + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", "type": "boolean" }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - } - }, - "required": [ - "name" - ], - "title": "Model", - "type": "object" - } - }, - "metrics": { - "type": "array", - "description": "Top-level metric definitions (optional - can also define in models)", - "items": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "agg": { + "retention_granularity": { "anyOf": [ { "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" + "day", + "week", + "month" ], "type": "string" }, @@ -1572,8 +1822,8 @@ } ], "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" }, "sql": { "anyOf": [ @@ -1588,26 +1838,25 @@ "description": "SQL expression or formula (accepts 'expr' as alias)", "title": "Sql" }, - "dax": { + "steps": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "DAX expression source text", - "title": "Dax" + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" }, - "expression_language": { + "time_offset": { "anyOf": [ { - "enum": [ - "sql", - "dax" - ], "type": "string" }, { @@ -1615,8 +1864,8 @@ } ], "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" }, "type": { "anyOf": [ @@ -1640,7 +1889,7 @@ "description": "Metric type for complex calculations", "title": "Type" }, - "numerator": { + "value_format_name": { "anyOf": [ { "type": "string" @@ -1650,10 +1899,10 @@ } ], "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" }, - "denominator": { + "window": { "anyOf": [ { "type": "string" @@ -1663,10 +1912,10 @@ } ], "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" }, - "offset_window": { + "window_expression": { "anyOf": [ { "type": "string" @@ -1676,10 +1925,10 @@ } ], "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" }, - "window": { + "window_frame": { "anyOf": [ { "type": "string" @@ -1689,56 +1938,10 @@ } ], "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" - }, - "window_order": { + "window_order": { "anyOf": [ { "type": "string" @@ -1750,1528 +1953,1624 @@ "default": null, "description": "Window ORDER BY column (defaults to model's default_time_dimension)", "title": "Window Order" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "type": "array" + }, + "models": { + "description": "Model definitions", + "items": { + "$defs": { + "Dimension": { + "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", + "properties": { + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Human-readable description", + "title": "Description" }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio" + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "type": "string" + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "entity": { - "anyOf": [ - { - "type": "string" + "granularity": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base granularity for time dimensions", + "title": "Granularity" }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "base_event": { - "anyOf": [ - { - "type": "string" + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "conversion_event": { - "anyOf": [ - { + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique dimension name within model", + "title": "Name", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "steps": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "parent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", + "title": "Parent" }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "cohort_event": { - "anyOf": [ - { - "type": "string" + "public": { + "default": true, + "description": "Whether dimension is visible in API/UI", + "title": "Public", + "type": "boolean" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "activity_event": { - "anyOf": [ - { - "type": "string" + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression (defaults to name; accepts 'expr' as alias)", + "title": "Sql" }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "periods": { - "anyOf": [ - { - "type": "integer" + "supported_granularities": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Supported granularities for time dimensions", + "title": "Supported Granularities" }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "retention_granularity": { - "anyOf": [ - { + "type": { + "description": "Dimension type", "enum": [ - "day", - "week", - "month" + "categorical", + "time", + "boolean", + "numeric" ], + "title": "Type", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" }, - { - "type": "null" + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", + "title": "Window" } + }, + "required": [ + "name", + "type" ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" + "title": "Dimension", + "type": "object" }, - "entity_dimensions": { - "anyOf": [ - { + "Index": { + "description": "Index definition for pre-aggregation performance.", + "properties": { + "columns": { + "description": "Columns to index", "items": { "type": "string" }, + "title": "Columns", "type": "array" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "having": { - "anyOf": [ - { + "name": { + "description": "Index name", + "title": "Name", "type": "string" }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" - }, - "filters": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { + "type": { + "default": "regular", + "description": "Index type", + "enum": [ + "regular", + "aggregate" + ], + "title": "Type", "type": "string" - }, - { - "type": "null" } + }, + "required": [ + "name", + "columns" ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" + "title": "Index", + "type": "object" }, - "description": { - "anyOf": [ - { - "type": "string" + "Metric": { + "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", + "properties": { + "activity_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", + "title": "Activity Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" + "agg": { + "anyOf": [ + { + "enum": [ + "sum", + "count", + "count_distinct", + "avg", + "min", + "max", + "median", + "stddev", + "stddev_pop", + "variance", + "variance_pop" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Aggregation function (for simple measures)", + "title": "Agg" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "base_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Starting event filter", + "title": "Base Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" + "base_metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base metric for time comparison", + "title": "Base Metric" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" + "calculation": { + "anyOf": [ + { + "enum": [ + "difference", + "percent_change", + "ratio" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Comparison calculation (default: percent_change)", + "title": "Calculation" }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "drill_fields": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" + "cohort_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", + "title": "Cohort Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" + "comparison_type": { + "anyOf": [ + { + "enum": [ + "yoy", + "mom", + "wow", + "dod", + "qoq", + "prior_period" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Type of time comparison", + "title": "Comparison Type" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "conversion_event": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Target event filter", + "title": "Conversion Event" }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - } - }, - "parameters": { - "type": "array", - "description": "Parameter definitions for dynamic queries", - "items": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - }, - "description": { - "anyOf": [ - { - "type": "string" + "conversion_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Conversion time window", + "title": "Conversion Window" }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" + "denominator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Denominator measure for ratio", + "title": "Denominator" }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" - }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - } - }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" - } - } - }, - "required": [ - "models" - ], - "$defs": { - "Dimension": { - "description": "Dimension (attribute) definition.\n\nDimensions are used for grouping and filtering in queries.", - "properties": { - "name": { - "description": "Unique dimension name within model", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Dimension type", - "enum": [ - "categorical", - "time", - "boolean", - "numeric" - ], - "title": "Type", - "type": "string" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression (defaults to name; accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "granularity": { - "anyOf": [ - { - "enum": [ - "second", - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base granularity for time dimensions", - "title": "Granularity" - }, - "supported_granularities": { - "anyOf": [ - { - "items": { - "type": "string" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Supported granularities for time dimensions", - "title": "Supported Granularities" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "parent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent dimension for hierarchies (e.g., 'state' parent is 'country')", - "title": "Parent" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window function expression (e.g., 'LEAD(event) OVER (PARTITION BY person_id ORDER BY timestamp)')", - "title": "Window" - }, - "public": { - "default": true, - "description": "Whether dimension is visible in API/UI", - "title": "Public", - "type": "boolean" - } - }, - "required": [ - "name", - "type" - ], - "title": "Dimension", - "type": "object" - }, - "Metric": { - "description": "Measure definition - supports simple aggregations and complex metric types.\n\nMeasures can be:\n- Simple aggregations: SUM(amount), COUNT(*), AVG(price)\n- Ratios: revenue / order_count\n- Derived formulas: (revenue - cost) / revenue\n- Cumulative: running totals, period-to-date\n- Time comparisons: YoY, MoM growth\n- Conversion funnels: signup -> purchase rate\n\nAuto-registers as a graph-level metric with the current semantic layer context if available.", - "properties": { - "name": { - "description": "Unique measure name", - "title": "Name", - "type": "string" - }, - "extends": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Parent metric to inherit from", - "title": "Extends" - }, - "agg": { - "anyOf": [ - { - "enum": [ - "sum", - "count", - "count_distinct", - "avg", - "min", - "max", - "median", - "stddev", - "stddev_pop", - "variance", - "variance_pop" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Aggregation function (for simple measures)", - "title": "Agg" - }, - "sql": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL expression or formula (accepts 'expr' as alias)", - "title": "Sql" - }, - "dax": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "DAX expression source text", - "title": "Dax" - }, - "expression_language": { - "anyOf": [ - { - "enum": [ - "sql", - "dax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Expression language for sql/expr/dax authoring", - "title": "Expression Language" - }, - "type": { - "anyOf": [ - { - "enum": [ - "ratio", - "derived", - "cumulative", - "time_comparison", - "conversion", - "retention", - "cohort" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Metric type for complex calculations", - "title": "Type" - }, - "numerator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Numerator measure for ratio", - "title": "Numerator" - }, - "denominator": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Denominator measure for ratio", - "title": "Denominator" - }, - "offset_window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time offset for denominator (e.g., '1 month')", - "title": "Offset Window" - }, - "window": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time window for cumulative (e.g., '7 days')", - "title": "Window" - }, - "grain_to_date": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month", - "quarter", - "year" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Grain for period-to-date (e.g., 'month' for MTD)", - "title": "Grain To Date" - }, - "window_expression": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", - "title": "Window Expression" - }, - "window_frame": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", - "title": "Window Frame" - }, - "window_order": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Window ORDER BY column (defaults to model's default_time_dimension)", - "title": "Window Order" - }, - "base_metric": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Base metric for time comparison", - "title": "Base Metric" - }, - "comparison_type": { - "anyOf": [ - { - "enum": [ - "yoy", - "mom", - "wow", - "dod", - "qoq", - "prior_period" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Type of time comparison", - "title": "Comparison Type" - }, - "time_offset": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Custom time offset (e.g., '1 month')", - "title": "Time Offset" - }, - "calculation": { - "anyOf": [ - { - "enum": [ - "difference", - "percent_change", - "ratio" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Comparison calculation (default: percent_change)", - "title": "Calculation" - }, - "entity": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Entity to track (e.g., 'user_id')", - "title": "Entity" - }, - "base_event": { - "anyOf": [ - { - "type": "string" + "drill_fields": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fields to show when drilling into this metric", + "title": "Drill Fields" + }, + "entity": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Entity to track (e.g., 'user_id')", + "title": "Entity" + }, + "entity_dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", + "title": "Entity Dimensions" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/expr/dax authoring", + "title": "Expression Language" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent metric to inherit from", + "title": "Extends" + }, + "fill_nulls_with": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default value when result is NULL", + "title": "Fill Nulls With" + }, + "filters": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional WHERE clause filters", + "title": "Filters" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display format string (e.g., '$#,##0.00', '0.00%')", + "title": "Format" + }, + "grain_to_date": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Grain for period-to-date (e.g., 'month' for MTD)", + "title": "Grain To Date" + }, + "having": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HAVING filter on inner aggregation for cohort metrics", + "title": "Having" + }, + "inner_metrics": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", + "title": "Inner Metrics" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label", + "title": "Label" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Unique measure name", + "title": "Name", + "type": "string" + }, + "non_additive_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", + "title": "Non Additive Dimension" + }, + "numerator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Numerator measure for ratio", + "title": "Numerator" + }, + "offset_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time offset for denominator (e.g., '1 month')", + "title": "Offset Window" + }, + "periods": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Number of retention periods to compute (e.g., 28 for 28-day)", + "title": "Periods" + }, + "public": { + "default": true, + "description": "Whether metric is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "retention_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for retention periods (day, week, month)", + "title": "Retention Granularity" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression or formula (accepts 'expr' as alias)", + "title": "Sql" + }, + "steps": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", + "title": "Steps" + }, + "time_offset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom time offset (e.g., '1 month')", + "title": "Time Offset" + }, + "type": { + "anyOf": [ + { + "enum": [ + "ratio", + "derived", + "cumulative", + "time_comparison", + "conversion", + "retention", + "cohort" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Metric type for complex calculations", + "title": "Type" + }, + "value_format_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", + "title": "Value Format Name" + }, + "window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window for cumulative (e.g., '7 days')", + "title": "Window" + }, + "window_expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Raw SQL expression for window function (e.g., 'AVG(total_bids) FILTER (WHERE active)')", + "title": "Window Expression" + }, + "window_frame": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window frame clause (e.g., 'RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW')", + "title": "Window Frame" + }, + "window_order": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Window ORDER BY column (defaults to model's default_time_dimension)", + "title": "Window Order" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Starting event filter", - "title": "Base Event" - }, - "conversion_event": { - "anyOf": [ - { - "type": "string" + "required": [ + "name" + ], + "title": "Metric", + "type": "object" + }, + "PreAggregation": { + "description": "Pre-aggregation definition for automatic query optimization.\n\nPre-aggregations are materialized rollup tables that store pre-computed\naggregations. The query engine automatically routes queries to matching\npre-aggregations for significant performance improvements.\n\nExample:\n >>> PreAggregation(\n ... name=\"daily_rollup\",\n ... measures=[\"count\", \"revenue\"],\n ... dimensions=[\"status\", \"region\"],\n ... time_dimension=\"created_at\",\n ... granularity=\"day\",\n ... partition_granularity=\"month\",\n ... refresh_key=RefreshKey(every=\"1 hour\", incremental=True)\n ... )", + "properties": { + "build_range_end": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for end of data range to aggregate", + "title": "Build Range End" + }, + "build_range_start": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for start of data range to aggregate", + "title": "Build Range Start" + }, + "dimensions": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Dimensions to group by (e.g., ['status', 'region'])", + "title": "Dimensions" + }, + "granularity": { + "anyOf": [ + { + "enum": [ + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time granularity for aggregation", + "title": "Granularity" + }, + "indexes": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Index" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Index definitions for query performance", + "title": "Indexes" + }, + "measures": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Measures to pre-aggregate (e.g., ['count', 'revenue'])", + "title": "Measures" + }, + "name": { + "description": "Unique pre-aggregation name", + "title": "Name", + "type": "string" + }, + "partition_granularity": { + "anyOf": [ + { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Partition size for incremental refresh", + "title": "Partition Granularity" + }, + "refresh_key": { + "anyOf": [ + { + "$ref": "#/$defs/RefreshKey" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh strategy configuration" + }, + "scheduled_refresh": { + "default": true, + "description": "Whether to enable scheduled refresh", + "title": "Scheduled Refresh", + "type": "boolean" + }, + "time_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time dimension for temporal grouping", + "title": "Time Dimension" + }, + "type": { + "default": "rollup", + "description": "Pre-aggregation type", + "enum": [ + "rollup", + "original_sql", + "rollup_join", + "lambda" + ], + "title": "Type", + "type": "string" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Target event filter", - "title": "Conversion Event" - }, - "conversion_window": { - "anyOf": [ - { - "type": "string" + "required": [ + "name" + ], + "title": "PreAggregation", + "type": "object" + }, + "RefreshKey": { + "description": "Refresh strategy configuration for pre-aggregations.", + "properties": { + "every": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Refresh interval (e.g., '1 hour', '1 day', '30 minutes')", + "title": "Every" + }, + "incremental": { + "default": false, + "description": "Whether to use incremental refresh (only update changed partitions)", + "title": "Incremental", + "type": "boolean" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL query that returns a value to trigger refresh when changed", + "title": "Sql" + }, + "update_window": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Time window to refresh incrementally (e.g., '7 day', '1 month')", + "title": "Update Window" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Conversion time window", - "title": "Conversion Window" - }, - "steps": { - "anyOf": [ - { - "items": { + "title": "RefreshKey", + "type": "object" + }, + "Relationship": { + "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", + "properties": { + "active": { + "default": true, + "description": "Whether the relationship is active by default", + "title": "Active", + "type": "boolean" + }, + "foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", + "title": "Foreign Key" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "name": { + "description": "Name of the related model", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "N-step funnel filter expressions (overrides base_event/conversion_event)", - "title": "Steps" - }, - "cohort_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for cohort-defining event (e.g., \"event = 'install'\")", - "title": "Cohort Event" - }, - "activity_event": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "SQL filter for activity event (default: any event, e.g., \"event IS NOT NULL\")", - "title": "Activity Event" - }, - "periods": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Number of retention periods to compute (e.g., 28 for 28-day)", - "title": "Periods" - }, - "retention_granularity": { - "anyOf": [ - { - "enum": [ - "day", - "week", - "month" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Time granularity for retention periods (day, week, month)", - "title": "Retention Granularity" - }, - "inner_metrics": { - "anyOf": [ - { - "items": { - "additionalProperties": true, - "type": "object" + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Primary key column(s) in related model (defaults to id)", + "title": "Primary Key" + }, + "related_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to related model", + "title": "Related Foreign Key" + }, + "through": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Junction model for many_to_many relationships", + "title": "Through" }, - "type": "array" + "through_foreign_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Foreign key in junction model pointing to this model", + "title": "Through Foreign Key" + }, + "type": { + "description": "Type of relationship", + "enum": [ + "many_to_one", + "one_to_one", + "one_to_many", + "many_to_many" + ], + "title": "Type", + "type": "string" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "Per-entity aggregations for cohort metrics (list of {name, agg, sql})", - "title": "Inner Metrics" - }, - "entity_dimensions": { - "anyOf": [ - { - "items": { + "required": [ + "name", + "type" + ], + "title": "Relationship", + "type": "object" + }, + "Segment": { + "description": "Segment definition - predefined reusable filter.\n\nSegments are named filters that can be applied to queries to consistently\nfilter data according to business definitions.\n\nExample:\n active_users = Segment(\n name=\"active_users\",\n sql=\"{model}.status = 'active' AND {model}.last_login > CURRENT_DATE - 30\"\n )", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "name": { + "description": "Unique segment name", + "title": "Name", "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimensions to carry through from inner to outer aggregation in cohort metrics", - "title": "Entity Dimensions" - }, - "having": { - "anyOf": [ - { - "type": "string" + "public": { + "default": true, + "description": "Whether segment is visible in API/UI", + "title": "Public", + "type": "boolean" + }, + "sql": { + "description": "SQL WHERE clause expression", + "title": "Sql", + "type": "string" + } }, - { - "type": "null" - } - ], - "default": null, - "description": "HAVING filter on inner aggregation for cohort metrics", - "title": "Having" + "required": [ + "name", + "sql" + ], + "title": "Segment", + "type": "object" + } }, - "filters": { - "anyOf": [ - { - "items": { + "description": "Model (dataset) definition.\n\nModels are the foundation of the semantic layer, mapping to physical tables\nor SQL expressions. Auto-registers with the current semantic layer context if available.", + "properties": { + "auto_dimensions": { + "default": false, + "description": "Auto-discover dimensions from database schema when added to a SemanticLayer", + "title": "Auto Dimensions", + "type": "boolean" + }, + "dax": { + "anyOf": [ + { "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional WHERE clause filters", - "title": "Filters" - }, - "fill_nulls_with": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Default value when result is NULL", - "title": "Fill Nulls With" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label", - "title": "Label" - }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - }, - "format": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display format string (e.g., '$#,##0.00', '0.00%')", - "title": "Format" - }, - "value_format_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Named format (e.g., 'usd', 'percent', 'decimal_2')", - "title": "Value Format Name" - }, - "drill_fields": { - "anyOf": [ - { - "items": { + { + "type": "null" + } + ], + "default": null, + "description": "DAX table expression source text", + "title": "Dax" + }, + "default_grain": { + "anyOf": [ + { + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default time granularity when using default_time_dimension", + "title": "Default Grain" + }, + "default_time_dimension": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Default time dimension for metrics (auto-included in queries)", + "title": "Default Time Dimension" + }, + "description": { + "anyOf": [ + { "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Fields to show when drilling into this metric", - "title": "Drill Fields" - }, - "non_additive_dimension": { - "anyOf": [ - { - "type": "string" + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "dimensions": { + "description": "Dimension definitions", + "items": { + "$ref": "#/$defs/Dimension" }, - { - "type": "null" - } - ], - "default": null, - "description": "Dimension across which this metric cannot be summed (e.g., time for averages)", - "title": "Non Additive Dimension" - }, - "meta": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" + "title": "Dimensions", + "type": "array" + }, + "expression_language": { + "anyOf": [ + { + "enum": [ + "sql", + "dax" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expression language for sql/dax derived table authoring", + "title": "Expression Language" + }, + "extends": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Parent model to inherit from", + "title": "Extends" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Arbitrary metadata for extensions", + "title": "Meta" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Adapter-specific metadata payload", + "title": "Metadata" + }, + "metrics": { + "description": "Measure definitions", + "items": { + "$ref": "#/$defs/Metric" }, - { - "type": "null" - } - ], - "default": null, - "description": "Arbitrary metadata for extensions", - "title": "Meta" - }, - "public": { - "default": true, - "description": "Whether metric is visible in API/UI", - "title": "Public", - "type": "boolean" - } - }, - "required": [ - "name" - ], - "title": "Metric", - "type": "object" - }, - "Relationship": { - "description": "Represents a relationship between models.\n\nRelationship types:\n- many_to_one: This model has a foreign key to another\n- one_to_one: This model is referenced by another with unique constraint\n- one_to_many: This model is referenced by another\n- many_to_many: This model relates to another through a junction table", - "properties": { - "name": { - "description": "Name of the related model", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Type of relationship", - "enum": [ - "many_to_one", - "one_to_one", - "one_to_many", - "many_to_many" - ], - "title": "Type", - "type": "string" - }, - "foreign_key": { - "anyOf": [ - { - "type": "string" + "title": "Metrics", + "type": "array" + }, + "name": { + "description": "Unique model name", + "title": "Name", + "type": "string" + }, + "pre_aggregations": { + "description": "Pre-aggregation definitions for query optimization", + "items": { + "$ref": "#/$defs/PreAggregation" }, - { - "items": { + "title": "Pre Aggregations", + "type": "array" + }, + "primary_key": { + "anyOf": [ + { "type": "string" }, - "type": "array" + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "id", + "description": "Primary key column(s)", + "title": "Primary Key" + }, + "relationships": { + "description": "Relationships to other models", + "items": { + "$ref": "#/$defs/Relationship" }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key column(s) (defaults to {name}_id for many_to_one)", - "title": "Foreign Key" - }, - "primary_key": { - "anyOf": [ - { - "type": "string" + "title": "Relationships", + "type": "array" + }, + "segments": { + "description": "Segment (named filter) definitions", + "items": { + "$ref": "#/$defs/Segment" }, - { - "items": { + "title": "Segments", + "type": "array" + }, + "source_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Remote data source URI (e.g., https://, s3://, gs://)", + "title": "Source Uri" + }, + "sql": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SQL expression for derived tables", + "title": "Sql" + }, + "table": { + "anyOf": [ + { "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Primary key column(s) in related model (defaults to id)", - "title": "Primary Key" - }, - "through": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Junction model for many_to_many relationships", - "title": "Through" - }, - "through_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to this model", - "title": "Through Foreign Key" - }, - "related_foreign_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Foreign key in junction model pointing to related model", - "title": "Related Foreign Key" - }, - "active": { - "default": true, - "description": "Whether the relationship is active by default", - "title": "Active", - "type": "boolean" + { + "type": "null" + } + ], + "default": null, + "description": "Physical table name (schema.table)", + "title": "Table" + }, + "unique_keys": { + "anyOf": [ + { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Unique key constraints (each is a list of columns)", + "title": "Unique Keys" + } }, - "metadata": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Adapter-specific metadata payload", - "title": "Metadata" - } + "required": [ + "name" + ], + "title": "Model", + "type": "object" }, - "required": [ - "name", - "type" - ], - "title": "Relationship", - "type": "object" + "type": "array" }, - "Parameter": { - "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", - "properties": { - "name": { - "description": "Unique parameter name", - "title": "Name", - "type": "string" - }, - "type": { - "description": "Parameter data type", - "enum": [ - "string", - "number", - "date", - "unquoted", - "yesno" - ], - "title": "Type", - "type": "string" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Human-readable description", - "title": "Description" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Display label for UI", - "title": "Label" - }, - "default_value": { - "default": null, - "description": "Default value if not provided", - "title": "Default Value" - }, - "allowed_values": { - "anyOf": [ - { - "items": {}, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "List of allowed values (for dropdown/select)", - "title": "Allowed Values" + "parameters": { + "description": "Parameter definitions for dynamic queries", + "items": { + "description": "Parameter definition for user input.\n\nDEPRECATED: Use Jinja templates instead of Parameters.\n\nParameters can be referenced in filters, SQL expressions, and metric definitions\nto create dynamic, user-configurable queries.", + "properties": { + "allowed_values": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of allowed values (for dropdown/select)", + "title": "Allowed Values" + }, + "default_to_today": { + "default": false, + "description": "Default to current date (for date parameters)", + "title": "Default To Today", + "type": "boolean" + }, + "default_value": { + "default": null, + "description": "Default value if not provided", + "title": "Default Value" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human-readable description", + "title": "Description" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Display label for UI", + "title": "Label" + }, + "name": { + "description": "Unique parameter name", + "title": "Name", + "type": "string" + }, + "type": { + "description": "Parameter data type", + "enum": [ + "string", + "number", + "date", + "unquoted", + "yesno" + ], + "title": "Type", + "type": "string" + } }, - "default_to_today": { - "default": false, - "description": "Default to current date (for date parameters)", - "title": "Default To Today", - "type": "boolean" - } + "required": [ + "name", + "type" + ], + "title": "Parameter", + "type": "object" }, - "required": [ - "name", - "type" - ], - "title": "Parameter", - "type": "object" + "type": "array" } - } + }, + "required": [ + "models" + ], + "title": "Sidemantic Semantic Layer", + "type": "object" } \ No newline at end of file From 3c0c96d371f49452f92c2ce7a2e62406536e1991 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Thu, 14 May 2026 09:00:29 -0700 Subject: [PATCH 5/9] Keep untranslated DAX out of metric SQL --- sidemantic/adapters/tmdl.py | 5 ++--- sidemantic/core/metric.py | 7 ++++++- sidemantic/sql/generator.py | 6 ++++++ sidemantic/validation.py | 26 ++++++++++++++++++++------ tests/adapters/tmdl/test_parsing.py | 19 +++++++++++++++---- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/sidemantic/adapters/tmdl.py b/sidemantic/adapters/tmdl.py index a392faf5..fb1db6b5 100644 --- a/sidemantic/adapters/tmdl.py +++ b/sidemantic/adapters/tmdl.py @@ -585,16 +585,15 @@ def _measure_to_metric( metric = Metric( name=node.name or "", agg=agg, - sql=sql or expression if not agg else sql, + sql=sql, dax=expression, + expression_language="dax", type=metric_type, description=node.description or _string_prop(_props(node).get("description")), label=_string_prop(_props(node).get("caption")), format=_string_prop(_props(node).get("formatstring")), public=not _is_true(props.get("ishidden")), ) - if expression: - metric.expression_language = "dax" metric._source_format = "TMDL" if node.location and node.location.file: try: diff --git a/sidemantic/core/metric.py b/sidemantic/core/metric.py index 1bc58d71..32c58630 100644 --- a/sidemantic/core/metric.py +++ b/sidemantic/core/metric.py @@ -198,7 +198,7 @@ def validate_type_specific_fields(self): raise ValueError("ratio metric requires 'numerator' field") if not self.denominator: raise ValueError("ratio metric requires 'denominator' field") - if self.type == "derived" and not self.sql: + if self.type == "derived" and not self.sql and not self.has_untranslated_dax: raise ValueError("derived metric requires 'sql' field") if self.type == "cumulative" and not self.sql and not self.window_expression: raise ValueError("cumulative metric requires 'sql' or 'window_expression' field") @@ -346,6 +346,11 @@ def sql_expr(self) -> str: return "*" return self.sql or self.name + @property + def has_untranslated_dax(self) -> bool: + """Whether this metric preserves DAX source without a SQL translation.""" + return self.expression_language == "dax" and bool(self.dax) and not self.sql and not self.agg + @property def is_simple_aggregation(self) -> bool: """Check if this is a simple aggregation (not a complex metric).""" diff --git a/sidemantic/sql/generator.py b/sidemantic/sql/generator.py index 6f436ec7..27d2c132 100644 --- a/sidemantic/sql/generator.py +++ b/sidemantic/sql/generator.py @@ -2229,6 +2229,12 @@ def _build_metric_sql(self, metric, model_context: str | None = None) -> str: Returns: SQL expression string """ + if getattr(metric, "has_untranslated_dax", False): + raise ValueError( + f"Metric '{metric.name}' contains DAX expression but has no SQL translation. " + "DAX lowering is not available in this build." + ) + if metric.type == "ratio": # numerator / NULLIF(denominator, 0) if not metric.numerator or not metric.denominator: diff --git a/sidemantic/validation.py b/sidemantic/validation.py index e3edb6db..53c87eaa 100644 --- a/sidemantic/validation.py +++ b/sidemantic/validation.py @@ -175,8 +175,10 @@ def validate_metric(measure: "Metric", graph: "SemanticGraph") -> list[str]: ) elif measure.type == "derived": - if not measure.sql: + if not measure.sql and not getattr(measure, "has_untranslated_dax", False): errors.append(f"Derived measure '{measure.name}' must have 'expr' defined") + if getattr(measure, "has_untranslated_dax", False): + return errors # Auto-detect dependencies and check for circular references dependencies = measure.get_dependencies(graph) @@ -258,6 +260,13 @@ def validate_query(metrics: list[str], dimensions: list[str], graph: "SemanticGr """ errors = [] + def _add_untranslated_dax_error(metric_ref: str, measure: "Metric") -> None: + if getattr(measure, "has_untranslated_dax", False): + errors.append( + f"Metric '{metric_ref}' contains DAX expression but has no SQL translation. " + "DAX lowering is not available in this build." + ) + # Validate metric references for metric_ref in metrics: if "." in metric_ref: @@ -266,14 +275,19 @@ def validate_query(metrics: list[str], dimensions: list[str], graph: "SemanticGr model = graph.models.get(model_name) if not model: errors.append(f"Model '{model_name}' not found (referenced in '{metric_ref}')") - elif not model.get_metric(measure_name): - errors.append( - f"Metric '{measure_name}' not found in model '{model_name}' (referenced in '{metric_ref}')" - ) + else: + measure = model.get_metric(measure_name) + if not measure: + errors.append( + f"Metric '{measure_name}' not found in model '{model_name}' (referenced in '{metric_ref}')" + ) + else: + _add_untranslated_dax_error(metric_ref, measure) else: # Metric reference try: - graph.get_metric(metric_ref) + measure = graph.get_metric(metric_ref) + _add_untranslated_dax_error(metric_ref, measure) except KeyError: errors.append(f"Metric '{metric_ref}' not found") diff --git a/tests/adapters/tmdl/test_parsing.py b/tests/adapters/tmdl/test_parsing.py index 197a96f6..f8577093 100644 --- a/tests/adapters/tmdl/test_parsing.py +++ b/tests/adapters/tmdl/test_parsing.py @@ -18,6 +18,7 @@ from sidemantic.core.relationship import Relationship from sidemantic.core.semantic_graph import SemanticGraph from sidemantic.loaders import load_from_directory +from sidemantic.validation import QueryValidationError # ============================================================================= # BASIC PARSING TESTS @@ -58,7 +59,7 @@ def test_import_tmdl_directory(): sales_ly = sales.get_metric("Sales LY") assert sales_ly.type == "derived" assert sales_ly.expression_language == "dax" - assert sales_ly.sql == sales_ly.dax + assert sales_ly.sql is None assert "SAMEPERIODLASTYEAR" in sales_ly.dax backtick = sales.get_metric("Backtick Measure") @@ -83,6 +84,14 @@ def test_import_tmdl_directory_does_not_warn_for_model_relationship_refs(): assert relationship_warnings == [] +def test_tmdl_untranslated_dax_metric_is_not_compiled_as_sql(): + layer = SemanticLayer() + load_from_directory(layer, "tests/fixtures/tmdl") + + with pytest.raises(QueryValidationError, match="DAX expression but has no SQL translation"): + layer.compile(metrics=["Sales.Sales LY"]) + + def test_tmdl_export_preserves_model_ref_table_literals_and_order(): graph = TMDLAdapter().parse("tests/fixtures/tmdl") @@ -506,7 +515,9 @@ def test_tmdl_measure_derived_expression(): graph = adapter.parse(temp_path) metric = graph.models["test"].get_metric("avg_price") assert metric.type == "derived" - assert "SUM" in metric.sql + assert metric.expression_language == "dax" + assert metric.sql is None + assert "SUM" in metric.dax finally: temp_path.unlink() @@ -538,7 +549,7 @@ def test_tmdl_measure_preserves_complex_dax_source(): assert sales_ly_inline.type == "derived" assert sales_ly_inline.expression_language == "dax" assert sales_ly_inline.dax == "CALCULATE(SUM('Sales'[Amount]), SAMEPERIODLASTYEAR('Sales'[Order Date]))" - assert sales_ly_inline.sql == sales_ly_inline.dax + assert sales_ly_inline.sql is None assert [metric.name for metric in model.metrics] == ["Sales LY Inline"] finally: temp_path.unlink() @@ -572,7 +583,7 @@ def test_tmdl_measure_preserves_totalytd_dax_source(): assert metric.type == "derived" assert metric.expression_language == "dax" assert metric.dax == "TOTALYTD(CALCULATE(SUM(Sales[Amount]), Sales[ProductKey] = 1), Sales[OrderDate])" - assert metric.sql == metric.dax + assert metric.sql is None finally: temp_path.unlink() From 3466fa4015e8a96bff018dd28431a106691c229d Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Thu, 14 May 2026 10:48:54 -0700 Subject: [PATCH 6/9] Keep untranslated DAX columns out of SQL --- sidemantic/adapters/tmdl.py | 9 +++------ sidemantic/core/dimension.py | 5 +++++ sidemantic/sql/generator.py | 10 ++++++++++ sidemantic/validation.py | 17 +++++++++++++++-- tests/adapters/tmdl/test_parsing.py | 21 ++++++++++++++++++++- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/sidemantic/adapters/tmdl.py b/sidemantic/adapters/tmdl.py index fb1db6b5..f5efbb08 100644 --- a/sidemantic/adapters/tmdl.py +++ b/sidemantic/adapters/tmdl.py @@ -460,7 +460,7 @@ def _column_to_dimension( expression = expression_obj.text if expression_obj else None source_column = _string_prop(props.get("sourcecolumn")) - sql = source_column or expression + sql = source_column if expression: try: dax_expr = _parse_dax_expression(expression, node, "column") @@ -486,9 +486,7 @@ def _column_to_dimension( dax_expr = None else: dax_expr = None - if expression and not sql: - sql = source_column or expression - if not sql: + if not sql and not expression: sql = node.name or "" dimension = Dimension( @@ -496,14 +494,13 @@ def _column_to_dimension( type=dim_type, sql=sql, dax=expression, + expression_language="dax" if expression else None, granularity=granularity, description=node.description or _string_prop(props.get("description")), label=_string_prop(props.get("caption")), format=_string_prop(props.get("formatstring")), public=not _is_true(props.get("ishidden")), ) - if expression: - dimension.expression_language = "dax" dimension._source_format = "TMDL" if node.location and node.location.file: try: diff --git a/sidemantic/core/dimension.py b/sidemantic/core/dimension.py index 67584810..92d4b582 100644 --- a/sidemantic/core/dimension.py +++ b/sidemantic/core/dimension.py @@ -86,6 +86,11 @@ def sql_expr(self) -> str: """ return self.sql or self.name + @property + def has_untranslated_dax(self) -> bool: + """Whether this dimension preserves DAX source without a SQL translation.""" + return self.expression_language == "dax" and bool(self.dax) and not self.sql + @property def window_sql_expr(self) -> str: """Get the window SQL expression if set, otherwise fall back to sql_expr. diff --git a/sidemantic/sql/generator.py b/sidemantic/sql/generator.py index 27d2c132..992fe8d0 100644 --- a/sidemantic/sql/generator.py +++ b/sidemantic/sql/generator.py @@ -1179,6 +1179,7 @@ def replace_model_placeholder(sql_expr: str) -> str: # Add only needed dimension columns for dimension in model.dimensions: if dimension.name in needed_dimensions and dimension.name not in columns_added: + self._ensure_sql_dimension(model_name, dimension) # For time dimensions with granularity, apply DATE_TRUNC # Use window_sql_expr for CTE projection so window functions # (LEAD, LAG, etc.) are evaluated here. @@ -1202,6 +1203,7 @@ def replace_model_placeholder(sql_expr: str) -> str: if not dimension: continue + self._ensure_sql_dimension(model_name, dimension) if gran and dimension.type == "time": # Apply time granularity (in addition to base column) @@ -1311,6 +1313,7 @@ def collect_measures_from_metric(metric_ref: str, visited: set[str] | None = Non continue dim = model.get_dimension(col_name) if dim: + self._ensure_sql_dimension(model_name, dim) dim_sql = replace_model_placeholder(dim.window_sql_expr) select_cols.append(f"{dim_sql} AS {self._quote_alias(col_name)}") columns_added.add(col_name) @@ -2219,6 +2222,13 @@ def _build_measure_aggregation_sql(self, model_name: str, measure) -> str: return f"COUNT({raw_col})" return f"{agg_func}({raw_col})" + def _ensure_sql_dimension(self, model_name: str, dimension) -> None: + if getattr(dimension, "has_untranslated_dax", False): + raise ValueError( + f"Dimension '{model_name}.{dimension.name}' contains DAX expression but has no SQL translation. " + "DAX lowering is not available in this build." + ) + def _build_metric_sql(self, metric, model_context: str | None = None) -> str: """Build SQL expression for a metric. diff --git a/sidemantic/validation.py b/sidemantic/validation.py index 53c87eaa..697fb4cf 100644 --- a/sidemantic/validation.py +++ b/sidemantic/validation.py @@ -267,6 +267,13 @@ def _add_untranslated_dax_error(metric_ref: str, measure: "Metric") -> None: "DAX lowering is not available in this build." ) + def _add_untranslated_dax_dimension_error(dim_ref: str, dimension) -> None: + if getattr(dimension, "has_untranslated_dax", False): + errors.append( + f"Dimension '{dim_ref}' contains DAX expression but has no SQL translation. " + "DAX lowering is not available in this build." + ) + # Validate metric references for metric_ref in metrics: if "." in metric_ref: @@ -309,8 +316,14 @@ def _add_untranslated_dax_error(metric_ref: str, measure: "Metric") -> None: model = graph.models.get(model_name) if not model: errors.append(f"Model '{model_name}' not found (referenced in '{dim_ref}')") - elif not model.get_dimension(dim_name): - errors.append(f"Dimension '{dim_name}' not found in model '{model_name}' (referenced in '{dim_ref}')") + else: + dimension = model.get_dimension(dim_name) + if not dimension: + errors.append( + f"Dimension '{dim_name}' not found in model '{model_name}' (referenced in '{dim_ref}')" + ) + else: + _add_untranslated_dax_dimension_error(dim_ref, dimension) else: errors.append(f"Dimension reference '{dim_ref}' must be in 'model.dimension' format") diff --git a/tests/adapters/tmdl/test_parsing.py b/tests/adapters/tmdl/test_parsing.py index f8577093..be9ab480 100644 --- a/tests/adapters/tmdl/test_parsing.py +++ b/tests/adapters/tmdl/test_parsing.py @@ -18,6 +18,7 @@ from sidemantic.core.relationship import Relationship from sidemantic.core.semantic_graph import SemanticGraph from sidemantic.loaders import load_from_directory +from sidemantic.sql.generator import SQLGenerator from sidemantic.validation import QueryValidationError # ============================================================================= @@ -92,6 +93,21 @@ def test_tmdl_untranslated_dax_metric_is_not_compiled_as_sql(): layer.compile(metrics=["Sales.Sales LY"]) +def test_tmdl_untranslated_dax_dimension_is_not_compiled_as_sql(): + layer = SemanticLayer() + load_from_directory(layer, "tests/fixtures/tmdl_realistic") + + amount_x2 = layer.graph.models["Sales"].get_dimension("Amount x2") + assert amount_x2.sql is None + assert amount_x2.has_untranslated_dax + + with pytest.raises(QueryValidationError, match="DAX expression but has no SQL translation"): + layer.compile(metrics=["Sales.Total Sales"], dimensions=["Sales.Amount x2"]) + + with pytest.raises(ValueError, match="DAX expression but has no SQL translation"): + SQLGenerator(layer.graph).generate(metrics=["Sales.Total Sales"], dimensions=["Sales.Amount x2"]) + + def test_tmdl_export_preserves_model_ref_table_literals_and_order(): graph = TMDLAdapter().parse("tests/fixtures/tmdl") @@ -254,7 +270,7 @@ def test_tmdl_realistic_fixture_import_export_contract(tmp_path): sales = graph.models["Sales"] assert sales.description == "Sales fact table" assert sales.get_dimension("Amount x2").dax == "Sales[Amount] * 2" - assert sales.get_dimension("Amount x2").sql == "Sales[Amount] * 2" + assert sales.get_dimension("Amount x2").sql is None assert sales.get_metric("Total Sales").dax == "SUM(Sales[Amount])" assert sales.get_metric("Total Sales").sql == "Amount" assert getattr(sales, "_tmdl_child_nodes")[0].name == "TableTag" @@ -302,6 +318,7 @@ def test_tmdl_realistic_fixture_import_export_contract(tmp_path): reparsed_calculated = reparsed_graph.models["Sales By Category"] reparsed_rel = next(rel for rel in reparsed_sales.relationships if rel.name == "Products") assert reparsed_sales.get_dimension("Amount x2").dax == "Sales[Amount] * 2" + assert reparsed_sales.get_dimension("Amount x2").sql is None assert reparsed_sales.get_metric("Total Sales").dax == "SUM(Sales[Amount])" assert getattr(reparsed_calculated, "_tmdl_child_nodes")[0].name == "CalculationTag" assert getattr(reparsed_rel, "_tmdl_child_nodes")[0].name == "RelationshipLineage" @@ -1951,6 +1968,7 @@ def test_tmdl_export_preserves_native_dax_authored_sources(tmp_path): sales = reparsed.models["Sales"] positive_sales = reparsed.models["Positive Sales"] assert sales.get_dimension("Net").dax == "Sales[Amount] - 1" + assert sales.get_dimension("Net").sql is None assert sales.get_metric("Avg Price").dax == "DIVIDE(SUM(Sales[Amount]), SUM(Sales[Quantity]), 0)" assert positive_sales.dax == "FILTER(Sales, Sales[Amount] > 0)" assert positive_sales.table is None @@ -2469,6 +2487,7 @@ def test_tmdl_export_script_preserves_realistic_project_metadata(tmp_path): reparsed_calculated = reparsed.models["Sales By Category"] reparsed_rel = next(rel for rel in reparsed_sales.relationships if rel.name == "Products") assert reparsed_sales.get_dimension("Amount x2").dax == "Sales[Amount] * 2" + assert reparsed_sales.get_dimension("Amount x2").sql is None assert reparsed_sales.get_metric("Total Sales").dax == "SUM(Sales[Amount])" assert getattr(reparsed_calculated, "_tmdl_child_nodes")[0].name == "CalculationTag" assert getattr(reparsed_rel, "_tmdl_child_nodes")[0].name == "RelationshipLineage" From cfde4d64fa9b2a218d3423d0a0f9316eba288508 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Thu, 14 May 2026 10:53:26 -0700 Subject: [PATCH 7/9] Avoid DAX fallback in TMDL column metadata --- sidemantic/adapters/tmdl.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sidemantic/adapters/tmdl.py b/sidemantic/adapters/tmdl.py index f5efbb08..fe9746e6 100644 --- a/sidemantic/adapters/tmdl.py +++ b/sidemantic/adapters/tmdl.py @@ -290,9 +290,8 @@ def _collect_table_metadata( if child_type in ("column", "calculatedcolumn"): props = _props(child) dim_type, _ = _map_data_type(_string_prop(props.get("datatype"))) - expression = _resolve_expression(child, props) source_column = _string_prop(props.get("sourcecolumn")) - sql = source_column or expression or (child.name or "") + sql = source_column or (child.name or "") column_sql[child.name or ""] = sql if dim_type == "time" and child.name: time_dimensions.add(child.name) From 05f6a95e74322aba4edb7952b872db96b1f87667 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Thu, 14 May 2026 20:47:46 -0700 Subject: [PATCH 8/9] Fix Pyodide and DuckDB extension CI --- sidemantic-duckdb/CMakeLists.txt | 28 ++++++++++++++- sidemantic/core/semantic_layer.py | 20 +++++++++-- sidemantic/db/unavailable.py | 59 +++++++++++++++++++++++++++++++ tests/test_core_imports.py | 40 +++++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 sidemantic/db/unavailable.py diff --git a/sidemantic-duckdb/CMakeLists.txt b/sidemantic-duckdb/CMakeLists.txt index 0afd6ef2..7fbf2e0b 100644 --- a/sidemantic-duckdb/CMakeLists.txt +++ b/sidemantic-duckdb/CMakeLists.txt @@ -11,7 +11,33 @@ include_directories(src/include) # Path to sidemantic-rs set(SIDEMANTIC_RS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../sidemantic-rs") -set(SIDEMANTIC_LIB "${SIDEMANTIC_RS_DIR}/target/release/libsidemantic.a") +set(SIDEMANTIC_WORKSPACE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/..") +execute_process( + COMMAND cargo metadata --format-version 1 --no-deps + WORKING_DIRECTORY "${SIDEMANTIC_RS_DIR}" + OUTPUT_VARIABLE SIDEMANTIC_CARGO_METADATA + ERROR_QUIET + RESULT_VARIABLE SIDEMANTIC_CARGO_METADATA_RESULT) + +set(SIDEMANTIC_LIB_CANDIDATES) +if(SIDEMANTIC_CARGO_METADATA_RESULT EQUAL 0) + string(REGEX MATCH "\"target_directory\":\"([^\"]+)\"" _SIDEMANTIC_TARGET_MATCH "${SIDEMANTIC_CARGO_METADATA}") + if(_SIDEMANTIC_TARGET_MATCH) + list(APPEND SIDEMANTIC_LIB_CANDIDATES "${CMAKE_MATCH_1}/release/libsidemantic.a") + endif() +endif() +list(APPEND SIDEMANTIC_LIB_CANDIDATES + "${SIDEMANTIC_RS_DIR}/target/release/libsidemantic.a" + "${SIDEMANTIC_WORKSPACE_DIR}/target/release/libsidemantic.a") +foreach(SIDEMANTIC_LIB_CANDIDATE ${SIDEMANTIC_LIB_CANDIDATES}) + if(EXISTS "${SIDEMANTIC_LIB_CANDIDATE}") + set(SIDEMANTIC_LIB "${SIDEMANTIC_LIB_CANDIDATE}") + break() + endif() +endforeach() +if(NOT SIDEMANTIC_LIB) + message(FATAL_ERROR "libsidemantic.a not found. Run `cargo build --release` before building the DuckDB extension.") +endif() set(SIDEMANTIC_INCLUDE "${SIDEMANTIC_RS_DIR}/include") # Include Rust library headers diff --git a/sidemantic/core/semantic_layer.py b/sidemantic/core/semantic_layer.py index c2de808b..220618a8 100644 --- a/sidemantic/core/semantic_layer.py +++ b/sidemantic/core/semantic_layer.py @@ -74,9 +74,23 @@ def __init__( for stmt in init_sql: self.adapter.execute(stmt) elif connection.startswith("duckdb://"): - from sidemantic.db.duckdb import DuckDBAdapter - - self.adapter = DuckDBAdapter.from_url(connection, init_sql=init_sql) + try: + from sidemantic.db.duckdb import DuckDBAdapter + except ModuleNotFoundError as exc: + if exc.name != "duckdb": + raise + from sidemantic.db.unavailable import UnavailableDatabaseAdapter + + self.adapter = UnavailableDatabaseAdapter( + dialect="duckdb", + package="duckdb", + install_hint="Install with `pip install duckdb` or use a database adapter available in this environment.", + ) + if init_sql: + for stmt in init_sql: + self.adapter.execute(stmt) + else: + self.adapter = DuckDBAdapter.from_url(connection, init_sql=init_sql) self.dialect = dialect or "duckdb" elif connection.startswith(("postgres://", "postgresql://")): from sidemantic.db.postgres import PostgreSQLAdapter diff --git a/sidemantic/db/unavailable.py b/sidemantic/db/unavailable.py new file mode 100644 index 00000000..7ab61e2f --- /dev/null +++ b/sidemantic/db/unavailable.py @@ -0,0 +1,59 @@ +"""Database adapter used when a runtime dependency is unavailable.""" + +from __future__ import annotations + +from typing import Any + +from sidemantic.db.base import BaseDatabaseAdapter + + +class UnavailableDatabaseAdapter(BaseDatabaseAdapter): + """Adapter placeholder for compile-only environments.""" + + def __init__(self, *, dialect: str, package: str, install_hint: str): + self._dialect = dialect + self._package = package + self._install_hint = install_hint + + def _raise_unavailable(self) -> None: + raise ModuleNotFoundError( + f"Database runtime '{self._package}' is not installed. {self._install_hint}", + name=self._package, + ) + + def execute(self, sql: str) -> Any: + """Raise because SQL execution needs the missing runtime.""" + self._raise_unavailable() + + def executemany(self, sql: str, params: list) -> Any: + """Raise because SQL execution needs the missing runtime.""" + self._raise_unavailable() + + def fetchone(self, result: Any) -> tuple | None: + """Raise because result fetching needs the missing runtime.""" + self._raise_unavailable() + + def fetch_record_batch(self, result: Any) -> Any: + """Raise because Arrow fetching needs the missing runtime.""" + self._raise_unavailable() + + def get_tables(self) -> list[dict]: + """Raise because schema introspection needs the missing runtime.""" + self._raise_unavailable() + + def get_columns(self, table_name: str, schema: str | None = None) -> list[dict]: + """Raise because schema introspection needs the missing runtime.""" + self._raise_unavailable() + + def close(self) -> None: + """No-op: there is no underlying connection to close.""" + + @property + def dialect(self) -> str: + """Return the SQL dialect used for compile-only operations.""" + return self._dialect + + @property + def raw_connection(self) -> Any: + """Raise because direct connection access needs the missing runtime.""" + self._raise_unavailable() diff --git a/tests/test_core_imports.py b/tests/test_core_imports.py index f0c76f34..896a4f66 100644 --- a/tests/test_core_imports.py +++ b/tests/test_core_imports.py @@ -59,3 +59,43 @@ def test_non_dax_yaml_load_does_not_load_optional_dax_runtime(tmp_path): "models": ["orders"], "sidemantic_dax_loaded": False, } + + +def test_semantic_layer_can_construct_without_duckdb_runtime(): + code = """ +import builtins +import json + +real_import = builtins.__import__ + +def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "duckdb": + raise ModuleNotFoundError("No module named 'duckdb'", name="duckdb") + return real_import(name, globals, locals, fromlist, level) + +builtins.__import__ = blocked_import + +from sidemantic import SemanticLayer + +layer = SemanticLayer() +try: + layer.adapter.execute("select 1") +except ModuleNotFoundError as exc: + error_name = exc.name +else: + error_name = None + +print(json.dumps({ + "dialect": layer.dialect, + "adapter": type(layer.adapter).__name__, + "error_name": error_name, +})) +""" + + result = subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, text=True) + + assert json.loads(result.stdout) == { + "dialect": "duckdb", + "adapter": "UnavailableDatabaseAdapter", + "error_name": "duckdb", + } From 172b2fe3417affbba86a4404c26e12c16203b38a Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Thu, 14 May 2026 21:29:53 -0700 Subject: [PATCH 9/9] Address DAX TMDL review guards --- sidemantic/adapters/sidemantic.py | 5 + sidemantic/adapters/tmdl.py | 30 ++++-- sidemantic/core/model.py | 5 + sidemantic/sql/generator.py | 12 +++ sidemantic/sql/query_rewriter.py | 5 + sidemantic/validation.py | 9 ++ tests/adapters/tmdl/test_parsing.py | 140 ++++++++++++++++++++++++++++ 7 files changed, 200 insertions(+), 6 deletions(-) diff --git a/sidemantic/adapters/sidemantic.py b/sidemantic/adapters/sidemantic.py index 48595371..b6310c4b 100644 --- a/sidemantic/adapters/sidemantic.py +++ b/sidemantic/adapters/sidemantic.py @@ -553,6 +553,8 @@ def _export_model(self, model: Model) -> dict: if dim_dax: dim_def["dax"] = dim_dax dim_def["expression_language"] = "dax" + if dim.sql: + dim_def["sql"] = dim.sql elif dim.sql: dim_def["sql"] = dim.sql if dim.granularity: @@ -587,6 +589,8 @@ def _export_model(self, model: Model) -> dict: if measure_dax: measure_def["dax"] = measure_dax measure_def["expression_language"] = "dax" + if measure.sql: + measure_def["sql"] = measure.sql elif measure.sql: measure_def["sql"] = measure.sql if measure.filters: @@ -747,6 +751,7 @@ def _export_metric(self, measure: Metric, graph) -> dict: if measure_dax: result["dax"] = measure_dax result["expression_language"] = "dax" + result["sql"] = measure.sql else: result["sql"] = measure.sql # Auto-detect and export dependencies for derived measures diff --git a/sidemantic/adapters/tmdl.py b/sidemantic/adapters/tmdl.py index fe9746e6..a88ca771 100644 --- a/sidemantic/adapters/tmdl.py +++ b/sidemantic/adapters/tmdl.py @@ -1015,7 +1015,10 @@ def _extract_dax_agg( return None, None if func_lower == "countrows": - return agg, None + table = _parse_dax_table_ref(arg) + if table and table.lower() == table_name.lower(): + return agg, None + return None, None table, column = _parse_dax_column_ref(arg) if not column: @@ -1024,7 +1027,7 @@ def _extract_dax_agg( if table and table.lower() == table_name.lower(): return agg, column if table: - return agg, f"{table}.{column}" + return None, None return agg, column @@ -1070,13 +1073,21 @@ def unwrap(value: Any) -> Any: if not agg: return None - if func == "countrows": - return agg, None - if len(expr.args) != 1: return None arg = unwrap(expr.args[0]) + if func == "countrows": + if isinstance(arg, dax_ast.TableRef): + table = arg.table.name + elif isinstance(arg, dax_ast.Identifier): + table = arg.name + else: + return None + if table.lower() == table_name.lower(): + return agg, None + return None + if isinstance(arg, dax_ast.TableColumnRef): table = arg.table.name column = arg.column @@ -1092,7 +1103,7 @@ def unwrap(value: Any) -> Any: if table and table.lower() == table_name.lower(): return agg, column if table: - return agg, f"{table}.{column}" + return None return agg, column @@ -1149,6 +1160,13 @@ def _parse_dax_column_ref(expression: str) -> tuple[str | None, str | None]: return None, _unquote_identifier(expr) +def _parse_dax_table_ref(expression: str) -> str | None: + expr = expression.strip() + if not expr or any(char in expr for char in "([."): + return None + return _unquote_identifier(expr) + + def _parse_column_reference(value: str | None) -> tuple[str | None, str | None]: if not value: return None, None diff --git a/sidemantic/core/model.py b/sidemantic/core/model.py index 9e00f1b1..745ec8f4 100644 --- a/sidemantic/core/model.py +++ b/sidemantic/core/model.py @@ -81,6 +81,11 @@ def primary_key_columns(self) -> list[str]: return [self.primary_key] return self.primary_key + @property + def has_untranslated_dax(self) -> bool: + """Whether this model preserves DAX source without a SQL/table translation.""" + return bool(self.dax) and not self.sql and not self.table + def get_dimension(self, name: str) -> Dimension | None: """Get dimension by name.""" for dimension in self.dimensions: diff --git a/sidemantic/sql/generator.py b/sidemantic/sql/generator.py index 992fe8d0..3b5ffe3c 100644 --- a/sidemantic/sql/generator.py +++ b/sidemantic/sql/generator.py @@ -1102,6 +1102,7 @@ def _build_model_cte( CTE SQL string """ model = self.graph.get_model(model_name) + self._ensure_sql_model(model_name, model) all_models = all_models or {model_name} needs_joins = len(all_models) > 1 @@ -2229,6 +2230,13 @@ def _ensure_sql_dimension(self, model_name: str, dimension) -> None: "DAX lowering is not available in this build." ) + def _ensure_sql_model(self, model_name: str, model) -> None: + if getattr(model, "has_untranslated_dax", False): + raise ValueError( + f"Model '{model_name}' contains DAX table expression but has no SQL/table translation. " + "DAX table lowering is not available in this build." + ) + def _build_metric_sql(self, metric, model_context: str | None = None) -> str: """Build SQL expression for a metric. @@ -2511,6 +2519,7 @@ def _generate_cohort_metric_query( if not model or not metric: raise ValueError(f"No model found for cohort metric {metric_name}") + self._ensure_sql_model(model_name or model.name, model) # Validate entity identifier if not _re.match(r"^[a-zA-Z_][a-zA-Z0-9_.]*$", metric.entity): @@ -2796,6 +2805,7 @@ def _generate_retention_query( if not model: raise ValueError(f"No model found for retention metric {metric_name}") + self._ensure_sql_model(model.name, model) # Defaults (use `is not None` to avoid converting 0 to the default) periods = metric.periods if metric.periods is not None else 28 @@ -3008,6 +3018,7 @@ def _generate_conversion_query( if not model: raise ValueError(f"No model found for conversion metric {metric_name}") + self._ensure_sql_model(model.name, model) # Build SQL with self-join pattern # base_events: filter for base_event @@ -3203,6 +3214,7 @@ def _generate_multistep_conversion_query( if not model: raise ValueError(f"No model found for conversion metric {metric_name}") + self._ensure_sql_model(model.name, model) # Find timestamp dimension: prefer model.default_time_dimension, fall back to first time dim timestamp_dim = None diff --git a/sidemantic/sql/query_rewriter.py b/sidemantic/sql/query_rewriter.py index f791e522..f21c19b6 100644 --- a/sidemantic/sql/query_rewriter.py +++ b/sidemantic/sql/query_rewriter.py @@ -764,6 +764,11 @@ def replace_table(table_expr: exp.Expression | None) -> exp.Expression | None: model = self.graph.get_model(model_name) alias = table_expr.alias_or_name + if getattr(model, "has_untranslated_dax", False): + raise ValueError( + f"Model '{model_name}' contains DAX table expression but has no SQL/table translation. " + "DAX table lowering is not available in this build." + ) if model.sql: return self._parse_relation_factor(f"({model.sql}) AS {alias}") if model.table: diff --git a/sidemantic/validation.py b/sidemantic/validation.py index 697fb4cf..507f4f7e 100644 --- a/sidemantic/validation.py +++ b/sidemantic/validation.py @@ -274,6 +274,13 @@ def _add_untranslated_dax_dimension_error(dim_ref: str, dimension) -> None: "DAX lowering is not available in this build." ) + def _add_untranslated_dax_model_error(model_ref: str, model) -> None: + if getattr(model, "has_untranslated_dax", False): + errors.append( + f"Model '{model_ref}' contains DAX table expression but has no SQL/table translation. " + "DAX table lowering is not available in this build." + ) + # Validate metric references for metric_ref in metrics: if "." in metric_ref: @@ -283,6 +290,7 @@ def _add_untranslated_dax_dimension_error(dim_ref: str, dimension) -> None: if not model: errors.append(f"Model '{model_name}' not found (referenced in '{metric_ref}')") else: + _add_untranslated_dax_model_error(model_name, model) measure = model.get_metric(measure_name) if not measure: errors.append( @@ -317,6 +325,7 @@ def _add_untranslated_dax_dimension_error(dim_ref: str, dimension) -> None: if not model: errors.append(f"Model '{model_name}' not found (referenced in '{dim_ref}')") else: + _add_untranslated_dax_model_error(model_name, model) dimension = model.get_dimension(dim_name) if not dimension: errors.append( diff --git a/tests/adapters/tmdl/test_parsing.py b/tests/adapters/tmdl/test_parsing.py index be9ab480..2071a869 100644 --- a/tests/adapters/tmdl/test_parsing.py +++ b/tests/adapters/tmdl/test_parsing.py @@ -7,9 +7,11 @@ from pathlib import Path import pytest +import yaml import sidemantic.adapters.tmdl as tmdl_module from sidemantic import SemanticLayer +from sidemantic.adapters.sidemantic import SidemanticAdapter from sidemantic.adapters.tmdl import TMDLAdapter from sidemantic.core.dimension import Dimension from sidemantic.core.introspection import describe_graph @@ -108,6 +110,38 @@ def test_tmdl_untranslated_dax_dimension_is_not_compiled_as_sql(): SQLGenerator(layer.graph).generate(metrics=["Sales.Total Sales"], dimensions=["Sales.Amount x2"]) +def test_tmdl_dax_only_calculated_table_is_not_compiled_as_sql(): + tmdl = textwrap.dedent( + """ + calculatedTable SalesByCategory = SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount])) + column Category + dataType: string + column Revenue + dataType: decimal + measure Revenue = SUM(SalesByCategory[Revenue]) + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + model = graph.models["SalesByCategory"] + assert model.has_untranslated_dax + + layer = SemanticLayer() + layer.graph = graph + with pytest.raises(QueryValidationError, match="DAX table expression but has no SQL/table translation"): + layer.compile(metrics=["SalesByCategory.Revenue"], dimensions=["SalesByCategory.Category"]) + + with pytest.raises(ValueError, match="DAX table expression but has no SQL/table translation"): + SQLGenerator(graph).generate(metrics=["SalesByCategory.Revenue"], dimensions=["SalesByCategory.Category"]) + finally: + temp_path.unlink() + + def test_tmdl_export_preserves_model_ref_table_literals_and_order(): graph = TMDLAdapter().parse("tests/fixtures/tmdl") @@ -510,6 +544,112 @@ def test_tmdl_measure_aggregation_mapping(): temp_path.unlink() +def test_tmdl_countrows_only_translates_current_table_counts(): + tmdl = textwrap.dedent( + """ + table Sales + column SaleID + dataType: int64 + column Amount + dataType: decimal + measure 'Sales Rows' = COUNTROWS(Sales) + measure 'Product Rows' = COUNTROWS(Products) + measure 'Filtered Sales Rows' = COUNTROWS(FILTER(Sales, Sales[Amount] > 0)) + table Products + column ProductID + dataType: int64 + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + sales = graph.models["Sales"] + + sales_rows = sales.get_metric("Sales Rows") + assert sales_rows.agg == "count" + assert sales_rows.sql is None + assert not sales_rows.has_untranslated_dax + + product_rows = sales.get_metric("Product Rows") + assert product_rows.type == "derived" + assert product_rows.agg is None + assert product_rows.sql is None + assert product_rows.has_untranslated_dax + + filtered_rows = sales.get_metric("Filtered Sales Rows") + assert filtered_rows.type == "derived" + assert filtered_rows.agg is None + assert filtered_rows.sql is None + assert filtered_rows.has_untranslated_dax + finally: + temp_path.unlink() + + +def test_tmdl_cross_table_dax_aggregate_stays_untranslated(): + tmdl = textwrap.dedent( + """ + table Sales + column SaleID + dataType: int64 + measure 'Product Price' = SUM(Products[Price]) + table Products + column Price + dataType: decimal + """ + ) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".tmdl", delete=False) as f: + f.write(tmdl) + temp_path = Path(f.name) + + try: + graph = TMDLAdapter().parse(temp_path) + metric = graph.models["Sales"].get_metric("Product Price") + assert metric.type == "derived" + assert metric.agg is None + assert metric.sql is None + assert metric.has_untranslated_dax + finally: + temp_path.unlink() + + +def test_sidemantic_yaml_export_preserves_dax_metric_sql_translation(tmp_path): + tmdl = textwrap.dedent( + """ + table Sales + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = SUM(Sales[Amount]) + """ + ) + + source = tmp_path / "Sales.tmdl" + source.write_text(tmdl) + graph = TMDLAdapter().parse(source) + + output = tmp_path / "models.yml" + SidemanticAdapter().export(graph, output) + + data = yaml.safe_load(output.read_text()) + sales = data["models"][0] + revenue = sales["metrics"][0] + assert revenue["dax"] == "SUM(Sales[Amount])" + assert revenue["expression_language"] == "dax" + assert revenue["sql"] == "Amount" + + reparsed = SidemanticAdapter().parse(output) + metric = reparsed.models["Sales"].get_metric("Revenue") + assert metric.agg == "sum" + assert metric.sql == "Amount" + assert metric.dax == "SUM(Sales[Amount])" + assert not metric.has_untranslated_dax + + def test_tmdl_measure_derived_expression(): """Test complex DAX measures are treated as derived.""" tmdl = textwrap.dedent(