diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91643664..e73b9124 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,18 @@ 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' + import sidemantic_dax + + sidemantic_dax.parse_expression("1") + PY + update-schema: name: Update JSON Schema needs: python @@ -58,6 +89,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 +101,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 +127,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 +153,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 +192,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..d9dc9a2e 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,189 @@ 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' + import sidemantic_dax - - name: Build package - run: uv build + sidemantic_dax.parse_expression("1") + 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..4f617f42 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,48 @@ 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 preserve DAX expression source text for Power BI interoperability: + +```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 parsed and preserved in 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/") +``` + ## CLI ```bash @@ -236,7 +283,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-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-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..a75bf879 100644 --- a/sidemantic-schema.json +++ b/sidemantic-schema.json @@ -3,6 +3,19 @@ "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": [ { @@ -16,6 +29,23 @@ "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": [ { @@ -371,6 +401,19 @@ "description": "Conversion time window", "title": "Conversion Window" }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, "denominator": { "anyOf": [ { @@ -442,6 +485,23 @@ "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": [ { @@ -1118,6 +1178,12 @@ "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": [ { @@ -1425,6 +1491,19 @@ "description": "Conversion time window", "title": "Conversion Window" }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, "denominator": { "anyOf": [ { @@ -1496,6 +1575,23 @@ "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": [ { @@ -1874,6 +1970,19 @@ "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": [ { @@ -1887,6 +1996,23 @@ "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": [ { @@ -2242,6 +2368,19 @@ "description": "Conversion time window", "title": "Conversion Window" }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX expression source text", + "title": "Dax" + }, "denominator": { "anyOf": [ { @@ -2313,6 +2452,23 @@ "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": [ { @@ -2910,6 +3066,12 @@ "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": [ { @@ -3074,6 +3236,19 @@ "title": "Auto Dimensions", "type": "boolean" }, + "dax": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "DAX table expression source text", + "title": "Dax" + }, "default_grain": { "anyOf": [ { @@ -3131,6 +3306,23 @@ "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": [ { diff --git a/sidemantic/adapters/sidemantic.py b/sidemantic/adapters/sidemantic.py index 1fae00a7..b6310c4b 100644 --- a/sidemantic/adapters/sidemantic.py +++ b/sidemantic/adapters/sidemantic.py @@ -247,6 +247,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 +258,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 +281,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,6 +290,7 @@ 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"), @@ -376,6 +383,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 +423,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,6 +454,7 @@ 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"), @@ -484,10 +496,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 +532,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 +549,13 @@ 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" + if dim.sql: + dim_def["sql"] = dim.sql + elif dim.sql: dim_def["sql"] = dim.sql if dim.granularity: dim_def["granularity"] = dim.granularity @@ -551,6 +573,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 +585,13 @@ 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" + if measure.sql: + measure_def["sql"] = measure.sql + elif measure.sql: measure_def["sql"] = measure.sql if measure.filters: measure_def["filters"] = measure.filters @@ -628,6 +658,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 +747,40 @@ 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" + result["sql"] = measure.sql + 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 not measure.public: + result["public"] = measure.public return result + + +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..a88ca771 --- /dev/null +++ b/sidemantic/adapters/tmdl.py @@ -0,0 +1,2059 @@ +"""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 + +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) + ] + 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, + 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"))) + source_column = _string_prop(props.get("sourcecolumn")) + 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) + 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]], + 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, + 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_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") + 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: + 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, + 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 + 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 + 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 + 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 + if not sql and not expression: + sql = node.name or "" + + dimension = Dimension( + name=node.name or "", + 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")), + ) + 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]], + 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 + 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, + 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")), + ) + 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 _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 _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_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": + 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: + return None, None + + if table and table.lower() == table_name.lower(): + return agg, column + if table: + return None, None + 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 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 + 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 None + 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_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 + 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" or not model.table) + ) + 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/core/dimension.py b/sidemantic/core/dimension.py index 6b39c2b1..92d4b582 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") + 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" ) @@ -82,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/core/introspection.py b/sidemantic/core/introspection.py new file mode 100644 index 00000000..d478eaf1 --- /dev/null +++ b/sidemantic/core/introspection.py @@ -0,0 +1,324 @@ +"""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 + return owner_model is None + + +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 [], + "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 + + 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, + ) + + +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", "relationship_parse_skip"} + for warning in matched + ) + + +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 != []} diff --git a/sidemantic/core/metric.py b/sidemantic/core/metric.py index 78f0d93c..32c58630 100644 --- a/sidemantic/core/metric.py +++ b/sidemantic/core/metric.py @@ -50,6 +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") + expression_language: Literal["sql", "dax"] | None = Field( + None, description="Expression language for sql/expr/dax authoring" + ) @model_validator(mode="before") @classmethod @@ -83,10 +87,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 @@ -193,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") @@ -341,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/core/model.py b/sidemantic/core/model.py index 1286cb47..745ec8f4 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") + 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") @@ -77,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/core/relationship.py b/sidemantic/core/relationship.py index 8d350031..b5172122 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 diff --git a/sidemantic/core/semantic_graph.py b/sidemantic/core/semantic_graph.py index 6e91f336..66a67274 100644 --- a/sidemantic/core/semantic_graph.py +++ b/sidemantic/core/semantic_graph.py @@ -9,6 +9,13 @@ 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.""" @@ -43,6 +50,7 @@ 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._adjacency: dict[ str, list[tuple[str, list[str], list[str], str]] ] = {} # model -> [(to_model, from_keys, to_keys, rel_type)] @@ -212,6 +220,9 @@ 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,7 +269,7 @@ 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) diff --git a/sidemantic/core/semantic_layer.py b/sidemantic/core/semantic_layer.py index c41d51b3..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 @@ -480,6 +494,16 @@ def query( return self.adapter.execute(sql) + 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 +840,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 +852,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 +890,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/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/sidemantic/loaders.py b/sidemantic/loaders.py index 57da9c4a..6ff69c65 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 @@ -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,6 +192,13 @@ 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) @@ -171,6 +219,8 @@ 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() @@ -264,7 +314,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 +332,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 +393,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..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 @@ -1179,6 +1180,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 +1204,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 +1314,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 +2223,20 @@ 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 _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. @@ -2229,6 +2247,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: @@ -2495,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): @@ -2780,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 @@ -2992,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 @@ -3187,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 69dc94ef..507f4f7e 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: @@ -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,27 @@ 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." + ) + + 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." + ) + + 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: @@ -266,14 +289,20 @@ 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: + _add_untranslated_dax_model_error(model_name, model) + 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") @@ -295,8 +324,15 @@ 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 '{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: + _add_untranslated_dax_model_error(model_name, model) + 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_external_tmdl_fixtures.py b/tests/adapters/tmdl/test_external_tmdl_fixtures.py new file mode 100644 index 00000000..936e548b --- /dev/null +++ b/tests/adapters/tmdl/test_external_tmdl_fixtures.py @@ -0,0 +1,54 @@ +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, {}, 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, {}, 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..2071a869 --- /dev/null +++ b/tests/adapters/tmdl/test_parsing.py @@ -0,0 +1,2834 @@ +"""Tests for TMDL adapter.""" + +import difflib +import json +import tempfile +import textwrap +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 +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 +from sidemantic.sql.generator import SQLGenerator +from sidemantic.validation import QueryValidationError + +# ============================================================================= +# 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 == "derived" + assert sales_ly.expression_language == "dax" + assert sales_ly.sql is None + assert "SAMEPERIODLASTYEAR" in sales_ly.dax + + 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_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_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_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") + + 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 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" + + calculated = graph.models["Sales By Category"] + 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") + 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["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() + 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_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" + 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 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") + 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"] == 'SUMMARIZECOLUMNS(Products[Category], "Revenue", SUM(Sales[Amount]))' + 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_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( + """ + 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 metric.expression_language == "dax" + assert metric.sql is None + assert "SUM" in metric.dax + finally: + temp_path.unlink() + + +def test_tmdl_measure_preserves_complex_dax_source(): + 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 == "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 is None + assert [metric.name for metric in model.metrics] == ["Sales LY Inline"] + finally: + temp_path.unlink() + + +def test_tmdl_measure_preserves_totalytd_dax_source(): + 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 == "derived" + assert metric.expression_language == "dax" + assert metric.dax == "TOTALYTD(CALCULATE(SUM(Sales[Amount]), Sales[ProductKey] = 1), Sales[OrderDate])" + assert metric.sql is None + 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_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_inactive_relationship_is_preserved_and_excluded_from_graph_paths(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 warnings == [] + assert [(rel.name, rel.active) for rel in graph.models["Sales"].relationships] == [("Products", False)] + 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_invalid_relationship_edges_are_skipped(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] == ["relationship_parse_skip"] + assert warnings[0]["context"] == "relationship" + assert "unsupported cardinality" in warnings[0]["message"] + assert graph.models["Sales"].relationships == [] + 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: + 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_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] == [ + ("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["expression_language"] == "dax" + + +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 = BROKEN(Sales[Amount]) + table Returns + column Amount + dataType: decimal + sourceColumn: Amount + measure Revenue = BROKEN(Returns[Amount]) + """ + ) + + monkeypatch.setattr( + tmdl_module, + "_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: + 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_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 + 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_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" + 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_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/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_core_imports.py b/tests/test_core_imports.py new file mode 100644 index 00000000..896a4f66 --- /dev/null +++ b/tests/test_core_imports.py @@ -0,0 +1,101 @@ +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, + } + + +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", + } 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_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..10d41c0f 100644 --- a/tests/test_semantic_graph_errors.py +++ b/tests/test_semantic_graph_errors.py @@ -208,3 +208,54 @@ 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") 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(): 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"