diff --git a/.agents/skills/ci-prep/SKILL.md b/.agents/skills/ci-prep/SKILL.md index f990d876..d7fb2032 100644 --- a/.agents/skills/ci-prep/SKILL.md +++ b/.agents/skills/ci-prep/SKILL.md @@ -3,7 +3,7 @@ name: ci-prep description: Prepares the current branch for CI by running the exact same steps locally and fixing issues. If CI is already failing, fetches the GH Actions logs first to diagnose. Use before pushing, when CI is red, or when the user says "fix ci". argument-hint: "[--failing] [optional job name to focus on]" --- - + # CI Prep diff --git a/.agents/skills/code-dedup/SKILL.md b/.agents/skills/code-dedup/SKILL.md index e0cd6363..b9389ea9 100644 --- a/.agents/skills/code-dedup/SKILL.md +++ b/.agents/skills/code-dedup/SKILL.md @@ -2,7 +2,7 @@ name: code-dedup description: Searches for duplicate code, duplicate tests, and dead code, then safely merges or removes them. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup". Requires test coverage — refuses to touch untested code. --- - + # Code Dedup diff --git a/.agents/skills/fix-bug/SKILL.md b/.agents/skills/fix-bug/SKILL.md index 3eb8279c..fb30007b 100644 --- a/.agents/skills/fix-bug/SKILL.md +++ b/.agents/skills/fix-bug/SKILL.md @@ -4,7 +4,7 @@ description: Fix a bug using test-driven development. Use when the user reports argument-hint: "[bug description]" allowed-tools: Read, Grep, Glob, Edit, Write, Bash --- - + # Bug Fix Skill — Test-First Workflow diff --git a/.agents/skills/spec-check/SKILL.md b/.agents/skills/spec-check/SKILL.md index a077d8b3..540f101c 100644 --- a/.agents/skills/spec-check/SKILL.md +++ b/.agents/skills/spec-check/SKILL.md @@ -3,7 +3,7 @@ name: spec-check description: Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and matching logic. Use when the user says "check specs", "spec audit", or "verify specs". argument-hint: "[optional spec ID or filename filter]" --- - + # spec-check diff --git a/.agents/skills/submit-pr/SKILL.md b/.agents/skills/submit-pr/SKILL.md index e4c85b15..6ab06153 100644 --- a/.agents/skills/submit-pr/SKILL.md +++ b/.agents/skills/submit-pr/SKILL.md @@ -3,7 +3,7 @@ name: submit-pr description: Creates a pull request with a well-structured description after verifying CI passes. Use when the user asks to submit, create, or open a pull request. disable-model-invocation: true --- - + # Submit PR diff --git a/.agents/skills/upgrade-packages/SKILL.md b/.agents/skills/upgrade-packages/SKILL.md index 82a654b4..6dbfde1c 100644 --- a/.agents/skills/upgrade-packages/SKILL.md +++ b/.agents/skills/upgrade-packages/SKILL.md @@ -3,7 +3,7 @@ name: upgrade-packages description: Upgrade all dependencies/packages to their latest versions for the detected language(s). Use when the user says "upgrade packages", "update dependencies", "bump versions", "update packages", or "upgrade deps". argument-hint: "[--check-only] [--major] [package-name]" --- - + # Upgrade Packages diff --git a/.agents/skills/website-audit/SKILL.md b/.agents/skills/website-audit/SKILL.md index 6f786a0e..07ccf612 100644 --- a/.agents/skills/website-audit/SKILL.md +++ b/.agents/skills/website-audit/SKILL.md @@ -2,7 +2,7 @@ name: website-audit description: Audits a website for SEO, AI search performance, structured data, mobile usability, broken links, and social media cards. Fixes issues found. Use when the user mentions "audit website", "SEO", "fix search ranking", "AI search", "structured data", "social media cards", or "website performance". --- - + # Website Audit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..83d360b8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(make fmt-check *)", + "Bash(gh issue *)" + ] + }, + "autoMemoryEnabled": false +} diff --git a/.clinerules/00-read-instructions.md b/.clinerules/00-read-instructions.md index 48aaceb3..7bf96649 100644 --- a/.clinerules/00-read-instructions.md +++ b/.clinerules/00-read-instructions.md @@ -1,2 +1,2 @@ - + @CLAUDE.md diff --git a/.csharpierrc.json b/.csharpierrc.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.csharpierrc.json @@ -0,0 +1 @@ +{} diff --git a/.deslop.toml b/.deslop.toml new file mode 100644 index 00000000..dd7a6676 --- /dev/null +++ b/.deslop.toml @@ -0,0 +1,36 @@ +# agent-pmo:795a9c2 +# Deslop duplication gate — REPO-STANDARDS-SPEC [CI-DESLOP] +# Single source of truth for this repo's duplication budget. +# Docs: https://deslop.live/docs/for-ai/ +[defaults] +# Generated ANTLR outputs cannot be deduplicated by hand. Excluding them before +# analysis also avoids comparing millions of generated parser subtrees. +exclude = [ + # Reporting is outside the requested duplication audit scope. + "Reporting/**", + "DataProvider/Nimblesite.DataProvider.Postgres/Parsing/PostgreSQLLexer.cs", + "DataProvider/Nimblesite.DataProvider.Postgres/Parsing/PostgreSQLParser.cs", + "DataProvider/Nimblesite.DataProvider.Postgres/Parsing/PostgreSQLParserBaseListener.cs", + "DataProvider/Nimblesite.DataProvider.Postgres/Parsing/PostgreSQLParserBaseVisitor.cs", + "DataProvider/Nimblesite.DataProvider.Postgres/Parsing/PostgreSQLParserListener.cs", + "DataProvider/Nimblesite.DataProvider.Postgres/Parsing/PostgreSQLParserVisitor.cs", + "DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteLexer.cs", + "DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParser.cs", + "DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseListener.cs", + "DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserBaseVisitor.cs", + "DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserListener.cs", + "DataProvider/Nimblesite.DataProvider.SQLite/Parsing/SQLiteParserVisitor.cs", + "Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs", + "Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs", + "Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs", + "Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs", + "Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs", + "Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs", + "Lql/lql-lsp-rust/crates/lql-parser/src/generated/**", +] + +[threshold] +# Initial adoption ceiling measured with Deslop 0.36.0; main was 44.20213%. +# Written justification and reproduction: docs/specs/ci-spec.md [CI-DESLOP-BASELINE]. +# Keep all findings visible. Subsequent changes may only ratchet this DOWN. +max_duplication_percent = 39.44 diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh old mode 100755 new mode 100644 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 48aaceb3..7bf96649 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,2 +1,2 @@ - + @CLAUDE.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ec2e7f78 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,72 @@ +# agent-pmo:795a9c2 +# Dependabot — supply-chain defense without PR spam, and without burning CI on +# `main` for routine bumps. Portfolio standard ([GITHUB-DEPENDABOT]). +# +# Routine VERSION bumps target the long-lived `dependabot-upgrades` STAGING +# branch (never `main`). ci.yml / codeql.yml trigger only on +# `pull_request: [main]`, so staging PRs run NO build/test/CodeQL; they are +# auto-merged by .github/workflows/dependabot-automerge.yml. The batch reaches +# `main` through ONE consolidation PR where CI/CodeQL run exactly once. +# SECURITY updates ignore `target-branch` and still open against `main`. +# +# Ecosystems kept = the manifests THIS repo actually has: nuget (.csproj/.fsproj), +# cargo (Lql/lql-lsp-rust workspace), npm (root + Website + LqlExtension + +# LqlWebsite-Eleventy), and github-actions. No Python/Dart/Go manifests exist. +# +# REQUIREMENT: the `dependabot-upgrades` branch must exist and carry +# dependabot-automerge.yml (cut from main AFTER both land on main). +version: 2 + +updates: + # GitHub Actions — every repo with workflows. Pinned actions are a prime + # supply-chain target, so keep them current. + - package-ecosystem: github-actions + directory: / + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + github-actions: + patterns: ["*"] + + # NuGet / .NET — *.csproj, *.fsproj across the solution. + - package-ecosystem: nuget + directory: / + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + nuget: + patterns: ["*"] + + # Cargo / Rust — the LQL LSP workspace. + - package-ecosystem: cargo + directory: /Lql/lql-lsp-rust + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + cargo: + patterns: ["*"] + + # npm / Node — root tooling + both Eleventy sites + the VS Code extension. + - package-ecosystem: npm + directories: + - "/" + - "/Website" + - "/Lql/LqlExtension" + - "/Lql/LqlWebsite-Eleventy" + target-branch: "dependabot-upgrades" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: ["dependencies"] + groups: + npm: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 822f8f9b..caf4ac99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,30 @@ +# agent-pmo:795a9c2 name: CI on: pull_request: branches: [main] workflow_dispatch: + workflow_call: + +# Implements [SWR-SEC-TOKEN-PRIVILEGE]: build/test jobs only need repository reads. +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: - DOTNET_VERSION: '9.0.x' + # Projects target a mix of net9.0, net10.0 and netstandard2.1, so both SDKs + # are installed everywhere .NET builds (see the setup-dotnet steps below). DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true + # Implements [CI-TIMEOUT]: bound compiler memory and concurrency in direct dotnet steps. + DOTNET_PROCESSOR_COUNT: 2 + DOTNET_GCHeapHardLimit: '0x80000000' + DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: 1 + CARGO_BUILD_JOBS: 2 jobs: # Track 1: Format Check All -> Lint All -> DataProvider Tests @@ -23,10 +35,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET + - name: Setup .NET (9 + 10) uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-version: | + 9.0.x + 10.0.x - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -36,7 +50,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Cache NuGet packages uses: actions/cache@v4 @@ -70,6 +84,15 @@ jobs: - name: Lint run: make lint + - name: Test release version stamper + run: python3 tools/test-version-stamp.py + + - name: Install website dependencies + run: npm ci --prefix Website --ignore-scripts --no-audit --no-fund + + - name: Test documentation generator + run: python3 Website/scripts/test-api-docs.py + - name: Build CLI tools (needed by F# Type Provider MSBuild targets) run: | dotnet build Migration/DataProviderMigrate -c Debug @@ -115,13 +138,17 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET + - name: Setup .NET (9 + 10) uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-version: | + 9.0.x + 10.0.x - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview - name: Install cargo-tarpaulin run: cargo install cargo-tarpaulin @@ -214,7 +241,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -258,11 +285,20 @@ jobs: - name: Verify lql-lsp --version run: | ./Lql/lql-lsp-rust/target/release/lql-lsp --version - # Confirm the printed version matches the VSIX package.json version - BIN_VERSION=$(./Lql/lql-lsp-rust/target/release/lql-lsp --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + # Compare full versions, including prerelease suffixes. The committed + # placeholder is 0.0.0-dev on both sides until release stamping. + BIN_OUTPUT=$(./Lql/lql-lsp-rust/target/release/lql-lsp --version) + case "$BIN_OUTPUT" in + "lql-lsp "*) BIN_VERSION="${BIN_OUTPUT#* }" ;; + *) echo "ERROR: unexpected lql-lsp --version output: $BIN_OUTPUT"; exit 1 ;; + esac VSIX_VERSION=$(jq -r '.version' Lql/LqlExtension/package.json) echo "Binary version: $BIN_VERSION" echo "VSIX version: $VSIX_VERSION" + if [ -z "$BIN_VERSION" ] || [ -z "$VSIX_VERSION" ]; then + echo "ERROR: binary or VSIX version is empty" + exit 1 + fi if [ "$BIN_VERSION" != "$VSIX_VERSION" ]; then echo "ERROR: lql-lsp --version ($BIN_VERSION) does not match VSIX version ($VSIX_VERSION)" exit 1 @@ -280,3 +316,84 @@ jobs: - name: Package VSIX (dry run) run: cd Lql/LqlExtension && npm run compile && npx vsce package --no-git-tag-version --no-update-package-json + + # Duplication gate — Deslop ([CI-DESLOP]). Reads the threshold from .deslop.toml + # and exits 3 (failing the build) when measured duplication exceeds it. Owns the + # "duplicated code" concern; never hardcode the threshold here. + deslop: + name: Duplication (Deslop) + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DESLOP_VERSION: '0.36.0' + steps: + - uses: actions/checkout@v4 + - name: Install Deslop + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/deslop-bin" + curl -fsSL \ + "https://github.com/Nimblesite/Deslop/releases/download/v${DESLOP_VERSION}/deslop-${DESLOP_VERSION}-linux-x64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP/deslop-bin" --strip-components=1 \ + "deslop-${DESLOP_VERSION}-linux-x64/deslop" + echo "$RUNNER_TEMP/deslop-bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/deslop-bin/deslop" --version + - name: Check duplication (reads .deslop.toml) + run: deslop . + - name: Upload Deslop report + uses: actions/upload-artifact@v4 + if: always() + with: + name: deslop-report + path: .deslop/deslop-report.* + include-hidden-files: true + + # Vulnerable-dependency gate ([GITHUB-DEP-REVIEW]). Fails a PR that introduces a + # dependency with a known High+ advisory. Owns the "vulnerable deps" concern + # (CodeQL owns vulnerable code; make lint owns style). No overlap. + security: + name: Dependency review + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - name: Dependency review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + + # Aggregating gate. This is the SINGLE required status check for branch + # protection ([GITHUB-PROTECTION]): it passes only when every real job above + # passed, giving a stable check name regardless of how the matrix evolves. + ci: + name: CI + runs-on: ubuntu-latest + timeout-minutes: 5 + if: always() + needs: + - lint-and-dataprovider + - build-and-test + - extension-tests + - deslop + - security + steps: + - name: Verify all required jobs succeeded + run: | + set -euo pipefail + # `security` is skipped on non-PR events; treat skipped as acceptable, + # any failure/cancellation as fatal. + results='${{ toJSON(needs) }}' + echo "$results" + fail=0 + for job in lint-and-dataprovider build-and-test extension-tests deslop security; do + result=$(echo "$results" | jq -r --arg j "$job" '.[$j].result') + if [ "$job" = "security" ] && [ "$result" = "skipped" ]; then + continue + fi + if [ "$result" != "success" ]; then + echo "::error::Required job '$job' did not succeed (result: $result)" + fail=1 + fi + done + exit $fail diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..1802b2c0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,136 @@ +# agent-pmo:795a9c2 +name: CodeQL + +# CodeQL static security analysis ([GITHUB-CODE-SCANNING]). +# +# SEPARATE from ci.yml on purpose: CodeQL feeds GitHub code-scanning alerts and +# needs `security-events: write` + a weekly schedule, while ci.yml owns +# lint/test/build. It does NOT overlap with `make lint` (style/correctness) or +# dependency-review (vulnerable packages) — CodeQL finds vulnerable CODE. +# +# Matrix = (languages in this repo) ∩ (CodeQL-supported, checked at skill-run +# time): csharp, javascript-typescript (the LQL VS Code extension + Eleventy +# sites), rust (the LQL LSP), plus `actions` (always). F# is NOT CodeQL-supported +# and is therefore absent. Action SHAs are kept current by the github-actions +# Dependabot group ([GITHUB-DEPENDABOT]). +on: + pull_request: + branches: [main] + schedule: + # Weekly, so newly-published CodeQL queries re-scan even without a push. + - cron: "27 4 * * 1" + # release.yml calls this with gate=true on every v* tag to scan the exact + # released SHA with the current query set and BLOCK publishing on any + # High/Critical finding — a HARD gate, not advice. [GITHUB-CODE-SCANNING] + workflow_call: + inputs: + gate: + description: >- + When true (release calls), fail the job on any High/Critical finding so + the calling release workflow cannot publish. PR/weekly runs leave this + false and stay advisory (the PR check-failure threshold governs merges). + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + # Code scanning (SARIF upload) requires GitHub Advanced Security on PRIVATE + # repos. Gating on public visibility lets a private repo skip cleanly and + # self-enable the moment it is made public. + if: github.event.repository.visibility == 'public' + permissions: + security-events: write + actions: read + contents: read + strategy: + fail-fast: false + matrix: + # build-mode: none suits interpreted langs + rust + csharp (avoids + # recompiling what ci already builds). + include: + - language: actions # scans the workflow files themselves + build-mode: none + - language: csharp + build-mode: none + - language: javascript-typescript + build-mode: none + - language: rust + build-mode: none + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + category: "/language:${{ matrix.language }}" + # Drop SARIF on disk so the gate step can read it. `upload` stays on + # (default) so alerts still post to code scanning on every run. + output: sarif-results + # Release gate. `security-severity` is the 0-10 CVSS-style score CodeQL + # attaches to each security rule; >= 7.0 == High or Critical. Enforced ONLY + # on gated (release) calls. FAILS CLOSED: missing/malformed SARIF errors, + # never passes. [GITHUB-CODE-SCANNING] + - name: Enforce no high/critical findings (release gate) + if: inputs.gate + shell: bash + env: + SARIF_DIR: sarif-results + SEVERITY_THRESHOLD: '7.0' + run: |- + set -euo pipefail + shopt -s nullglob + # Fail closed: no SARIF means we cannot prove the code is clean. + sarifs=( "${SARIF_DIR}"/*.sarif ) + if [ "${#sarifs[@]}" -eq 0 ]; then + echo "::error::CodeQL gate: no SARIF in ${SARIF_DIR}; cannot verify findings — failing closed." + exit 1 + fi + offenders=0 + for sarif in "${sarifs[@]}"; do + if ! jq -e '.runs' "${sarif}" >/dev/null 2>&1; then + echo "::error::CodeQL gate: ${sarif} is not valid SARIF (no .runs) — failing closed." + exit 1 + fi + jq -r --arg f "${sarif##*/}" ' + ([ (.runs[].tool.driver.rules // [])[], + (.runs[].tool.extensions[]?.rules // [])[] ]) as $rules + | "CodeQL gate: \($f): results=\([.runs[].results[]?]|length) severity_rules=\([$rules[]|select(.properties["security-severity"])]|length)" + ' "${sarif}" + hits="$(jq -r --argjson t "${SEVERITY_THRESHOLD}" ' + .runs[] + | ( [ (.tool.driver.rules // [])[], + (.tool.extensions[]?.rules // [])[] ] + | map({ key: .id, + value: ((.properties["security-severity"] // "0") | tonumber) }) + | from_entries + ) as $severity + | .results[] + | select( ($severity[.ruleId] // 0) >= $t ) + | .ruleId + ' "${sarif}" | sort | uniq -c | sort -rn)" + if [ -n "${hits}" ]; then + echo "::error::High/critical CodeQL findings in ${sarif}:" + echo "${hits}" + offenders=$((offenders + 1)) + fi + done + if [ "${offenders}" -gt 0 ]; then + echo "::error::CodeQL gate failed — release blocked. Fix or dismiss-and-exclude the findings, then re-tag." + exit 1 + fi + echo "CodeQL gate passed: nothing at or above severity ${SEVERITY_THRESHOLD}." diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 00000000..9c4f9832 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,37 @@ +# agent-pmo:795a9c2 +name: Dependabot auto-merge + +# Auto-merges Dependabot version-bump PRs INTO the `dependabot-upgrades` staging +# branch so they never pile up for a human to clear ([GITHUB-DEPENDABOT]). +# +# Safe to merge unattended: nothing reaches `main` this way. The full +# build/test (ci.yml) + CodeQL (codeql.yml) gate the single +# `dependabot-upgrades -> main` consolidation PR, which is where review and the +# expensive matrix actually run. This is a ~10-second merge bot, NOT the CI +# pipeline. +# +# Lives at the repo root so it is present on `dependabot-upgrades` (cut from +# main): `pull_request` runs the workflow from the PR base ref. +on: + pull_request: + branches: + - dependabot-upgrades + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + name: Auto-merge into dependabot-upgrades + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Squash-merge the bump into the staging branch + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge --auto --squash --delete-branch "$PR_URL" \ + || gh pr merge --squash --delete-branch "$PR_URL" diff --git a/.github/workflows/publish-brew-tap.yml b/.github/workflows/publish-brew-tap.yml new file mode 100644 index 00000000..d7292102 --- /dev/null +++ b/.github/workflows/publish-brew-tap.yml @@ -0,0 +1,97 @@ +# Vendored from Shipwright templates/gh-actions/publish-brew-tap.yml +# Writes Formula/.rb (url/version/sha256 + --version test) to the tap repo. +# sha256 MUST come from the build's signed SHA256SUMS, never recomputed. [SWR-SEC-CHECKSUM] +name: publish-brew-tap + +on: + workflow_call: + inputs: + tap_repo: + required: true + type: string + formula_name: + required: true + type: string + version: + required: true + type: string + asset_url: + required: true + type: string + sha256: + required: true + type: string + binary_name: + required: true + type: string + homepage: + required: true + type: string + description: + required: true + type: string + secrets: + tap_token: + required: true + +# SWR-SEC-TOKEN-PRIVILEGE: this job checks out an EXTERNAL tap repo via tap_token +# and pushes to it; its own GITHUB_TOKEN needs no write. +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: ${{ inputs.tap_repo }} + token: ${{ secrets.tap_token }} + path: tap + - name: Write formula + shell: bash + working-directory: tap + env: + FORMULA_NAME: ${{ inputs.formula_name }} + DESCRIPTION: ${{ inputs.description }} + HOMEPAGE: ${{ inputs.homepage }} + ASSET_URL: ${{ inputs.asset_url }} + VERSION: ${{ inputs.version }} + SHA256: ${{ inputs.sha256 }} + BINARY_NAME: ${{ inputs.binary_name }} + run: | + set -euo pipefail + class_name="$(ruby -e 'puts ARGV[0].split(/[-_]/).map(&:capitalize).join' "$FORMULA_NAME")" + mkdir -p Formula + cat > "Formula/${FORMULA_NAME}.rb" </, package, verify contents, attest provenance, then publish each VSIX +# to the Marketplace via Entra OIDC (no stored PAT). +# [SWR-VSIX-PACKAGE] [SWR-VSIX-LAYOUT] [SWR-VSIX-VERIFY] [SWR-VSIX-PUBLISH-OIDC] [SWR-VSIX-PROVENANCE] +name: publish-vsix-per-platform + +on: + workflow_call: + inputs: + version: + required: true + type: string + pre_release: + required: false + type: boolean + default: false + marketplace_environment: + required: false + type: string + default: release + secrets: + azure_client_id: + required: false + azure_tenant_id: + required: false + +permissions: + contents: read + +env: + # Implements [CI-TIMEOUT]: bound compilation in each packaging matrix job. + CARGO_BUILD_JOBS: 2 + EXT_DIR: Lql/LqlExtension + RUST_DIR: Lql/lql-lsp-rust + BINARY_NAME: lql-lsp + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-15 + npm_arch: arm64 + - target: darwin-x64 + os: macos-15-intel + npm_arch: x64 + - target: linux-x64 + os: ubuntu-latest + npm_arch: x64 + - target: win32-x64 + os: windows-latest + npm_arch: x64 + runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22 + - name: Stamp version from tag + shell: bash + run: python3 tools/shipwright-version-stamp.py --tag "v${{ inputs.version }}" --root . + - name: Build lql-lsp (release, locked) + shell: bash + working-directory: ${{ env.RUST_DIR }} + run: cargo build --release --locked -p lql-lsp + - name: Sign + notarize darwin binary (before staging) + if: runner.os == 'macOS' && env.APPLE_TEAM_ID != '' + shell: bash + working-directory: ${{ env.RUST_DIR }} + env: + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + run: | + set -euo pipefail + codesign --force --options runtime --timestamp \ + --sign "Developer ID Application: ${APPLE_TEAM_ID}" \ + "target/release/${BINARY_NAME}" + # [SWR-SIGN-APPLE-INTEGRATION] notarization runs in the release env when + # the App Store Connect API-key secrets are configured (manual follow-up). + - name: Stage binary under bin// and pin .vscodeignore + shell: bash + run: | + set -euo pipefail + exe="" + if [ "${{ runner.os }}" = "Windows" ]; then exe=".exe"; fi + dest="${EXT_DIR}/bin/${{ matrix.target }}" + mkdir -p "$dest" + cp "${RUST_DIR}/target/release/${BINARY_NAME}${exe}" "${dest}/${BINARY_NAME}${exe}" + # Verify the staged binary's version matches the tag before packaging. + "${dest}/${BINARY_NAME}${exe}" --version | grep -Fx "${BINARY_NAME} ${{ inputs.version }}" + # Pattern B manual .vscodeignore: ship ONLY this target's bin dir, exclude + # all other platform dirs and dev artifacts. [SWR-VSIX-LAYOUT] + cat > "${EXT_DIR}/.vscodeignore" </dev/null + echo "archive=dist/${stage}.zip" >> "$GITHUB_OUTPUT" + else + tar -czf "${GITHUB_WORKSPACE}/dist/${stage}.tar.gz" -C target/release "${{ inputs.binary_name }}" + echo "archive=dist/${stage}.tar.gz" >> "$GITHUB_OUTPUT" + fi + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@f08f31f53bbe4451c46f9c5d8a8ff4cbb2c5ed27 # v0.21.0 + with: + file: ${{ steps.pkg.outputs.archive }} + format: cyclonedx-json + output-file: ${{ steps.pkg.outputs.archive }}.cdx.json + - name: Attest build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc20 # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + - name: Attest SBOM + uses: actions/attest-sbom@bd218ad7dbf483c81a22dde9694703a90f723a0d # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + sbom-path: ${{ steps.pkg.outputs.archive }}.cdx.json + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: ${{ inputs.binary_name }}-${{ matrix.platform }} + path: dist/* + if-no-files-found: error + + publish: + if: ${{ inputs.upload_release }} + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: dist + merge-multiple: true + - name: Generate SHA256SUMS + shell: bash + working-directory: dist + run: | + set -euo pipefail + shopt -s nullglob + mapfile -t files < <(find . -maxdepth 1 -type f ! -name 'SHA256SUMS' -printf '%P\n') + test "${#files[@]}" -gt 0 + shasum -a 256 "${files[@]}" > SHA256SUMS + - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 + - name: Sign SHA256SUMS (cosign keyless) + shell: bash + run: cosign sign-blob --yes --bundle dist/SHA256SUMS.sigstore.json dist/SHA256SUMS + - uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: v${{ inputs.version }} + files: dist/* diff --git a/.github/workflows/release-dotnet-aot-multiplatform.yml b/.github/workflows/release-dotnet-aot-multiplatform.yml new file mode 100644 index 00000000..adeaa199 --- /dev/null +++ b/.github/workflows/release-dotnet-aot-multiplatform.yml @@ -0,0 +1,169 @@ +# Sibling of release-binary-multiplatform.yml for the .NET Native AOT migration +# CLI. Builds the DataProviderMigrate binary per platform with PublishAot, runs a +# native smoke test, archives with SBOM + provenance, and uploads to the +# v{version} release alongside the Rust binary. Implements [MIG-AOT-CI]. +# [SWR-REL-GITHUB] [SWR-SEC-CHECKSUM] [SWR-SEC-PROVENANCE] [SWR-SEC-SBOM] +name: release-dotnet-aot-multiplatform + +on: + workflow_call: + inputs: + binary_name: + required: false + type: string + default: DataProviderMigrate + project: + required: false + type: string + default: Migration/DataProviderMigrate/DataProviderMigrate.csproj + version: + required: true + type: string + upload_release: + required: false + type: boolean + default: true + +permissions: + contents: read + +env: + # Implements [CI-TIMEOUT]: reusable workflows require their own build limits. + DOTNET_PROCESSOR_COUNT: 2 + DOTNET_GCHeapHardLimit: '0x80000000' + DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: 1 + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: darwin-arm64 + os: macos-15 + rid: osx-arm64 + - platform: darwin-x64 + os: macos-15-intel + rid: osx-x64 + - platform: linux-x64 + os: ubuntu-latest + rid: linux-x64 + - platform: win32-x64 + os: windows-latest + rid: win-x64 + runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: "10.0.x" + # Native AOT needs the platform C toolchain; ubuntu/macos runners ship it, + # Windows runners ship MSVC. Linux additionally needs clang + zlib headers. + - name: Install Linux AOT toolchain + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + - name: Publish Native AOT (${{ matrix.rid }}) + shell: bash + run: | + set -euo pipefail + dotnet publish "${{ inputs.project }}" \ + -c Release -r "${{ matrix.rid }}" \ + -p:PublishAot=true -p:InvariantGlobalization=true \ + --self-contained \ + -o "${GITHUB_WORKSPACE}/aot-out" + - name: Locate binary + id: bin + shell: bash + run: | + set -euo pipefail + exe="" + if [ "${{ runner.os }}" = "Windows" ]; then exe=".exe"; fi + path="${GITHUB_WORKSPACE}/aot-out/${{ inputs.binary_name }}${exe}" + test -f "$path" + echo "path=$path" >> "$GITHUB_OUTPUT" + echo "exe=$exe" >> "$GITHUB_OUTPUT" + - name: Native smoke test (migrate example schema → SQLite) + shell: bash + env: + DATAPROVIDERMIGRATE_AOT_BIN: ${{ steps.bin.outputs.path }} + run: | + set -euo pipefail + db="${RUNNER_TEMP}/aot_smoke.db" + "${DATAPROVIDERMIGRATE_AOT_BIN}" migrate \ + --schema Migration/DataProviderMigrate/example-schema.yaml \ + --output "$db" --provider sqlite + test -f "$db" + - name: Package archive + id: pkg + shell: bash + run: | + set -euo pipefail + stage="${{ inputs.binary_name }}-${{ inputs.version }}-${{ matrix.platform }}" + mkdir -p "${GITHUB_WORKSPACE}/dist" + if [ "${{ runner.os }}" = "Windows" ]; then + 7z a "${GITHUB_WORKSPACE}/dist/${stage}.zip" "${{ steps.bin.outputs.path }}" >/dev/null + echo "archive=dist/${stage}.zip" >> "$GITHUB_OUTPUT" + else + tar -czf "${GITHUB_WORKSPACE}/dist/${stage}.tar.gz" \ + -C "$(dirname "${{ steps.bin.outputs.path }}")" \ + "$(basename "${{ steps.bin.outputs.path }}")" + echo "archive=dist/${stage}.tar.gz" >> "$GITHUB_OUTPUT" + fi + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@f08f31f53bbe4451c46f9c5d8a8ff4cbb2c5ed27 # v0.21.0 + with: + file: ${{ steps.pkg.outputs.archive }} + format: cyclonedx-json + output-file: ${{ steps.pkg.outputs.archive }}.cdx.json + - name: Attest build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc20 # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + - name: Attest SBOM + uses: actions/attest-sbom@bd218ad7dbf483c81a22dde9694703a90f723a0d # v3.0.0 + with: + subject-path: ${{ github.workspace }}/${{ steps.pkg.outputs.archive }} + sbom-path: ${{ steps.pkg.outputs.archive }}.cdx.json + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: ${{ inputs.binary_name }}-${{ matrix.platform }} + path: dist/* + if-no-files-found: error + + publish: + if: ${{ inputs.upload_release }} + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + path: dist + merge-multiple: true + # Own signed checksum file (separate from the Rust binary's SHA256SUMS) so + # this workflow is self-contained. [SWR-SEC-CHECKSUM] + - name: Generate SHA256SUMS + shell: bash + working-directory: dist + run: | + set -euo pipefail + shopt -s nullglob + mapfile -t files < <(find . -maxdepth 1 -type f ! -name '*SHA256SUMS*' -printf '%P\n') + test "${#files[@]}" -gt 0 + shasum -a 256 "${files[@]}" > "${{ inputs.binary_name }}-SHA256SUMS" + - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 + - name: Sign SHA256SUMS (cosign keyless) + shell: bash + run: cosign sign-blob --yes --bundle "dist/${{ inputs.binary_name }}-SHA256SUMS.sigstore.json" "dist/${{ inputs.binary_name }}-SHA256SUMS" + - uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: v${{ inputs.version }} + files: dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd082e25..eb044774 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,121 +1,307 @@ -name: Release NuGet Packages +# agent-pmo:795a9c2 +# Tag-triggered release. Stamps the tag version into the runner working tree only +# (NO commit/push/tag bump after the tag exists — [SWR-VERSION-BUILD-STAMPING]), +# runs the CI + CodeQL hard gate, then publishes NuGet packages, per-platform binaries +# (GitHub Release), per-platform VSIX (Marketplace), Homebrew + Scoop, and deploys +# both documentation sites. [SWR-REL-WORKFLOW] [SWR-REL-GITHUB] +name: Release on: push: tags: - - 'v*' + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + +# Least privilege by default; write/id-token are granted per job. [SWR-SEC-TOKEN-PRIVILEGE] +permissions: + contents: read env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true + # Implements [CI-TIMEOUT]: apply compiler limits to release packing too. + DOTNET_PROCESSOR_COUNT: 2 + DOTNET_GCHeapHardLimit: '0x80000000' + DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER: 1 + OWNER_REPO: Nimblesite/DataProvider jobs: - publish: + # ── Stamp + gate ────────────────────────────────────────────────────────── + prepare: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 + environment: release permissions: - contents: write + contents: read outputs: - version: ${{ steps.version.outputs.VERSION }} + version: ${{ steps.v.outputs.version }} + prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false + - name: Derive version from tag + id: v + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + if [[ "$version" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Stamp version into runner working tree (never committed) + shell: bash + run: python3 tools/shipwright-version-stamp.py --tag "${GITHUB_REF_NAME}" --root . + - name: Validate Shipwright manifests + shell: bash + run: | + set -euo pipefail + python3 tools/validate-manifest.py shipwright.json + python3 tools/validate-manifest.py Lql/LqlExtension/shipwright.json - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.0.x' + # Implements [CI-RELEASE-GATE]: run the same CI on the tagged SHA before publishing. + ci-gate: + needs: prepare + uses: ./.github/workflows/ci.yml + permissions: + contents: read - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + # ── CodeQL hard release gate (blocks every publish job) ─────────────────── + codeql-gate: + needs: prepare + uses: ./.github/workflows/codeql.yml + with: + gate: true + permissions: + security-events: write + actions: read + contents: read + # ── NuGet packages ──────────────────────────────────────────────────────── + publish-nuget: + needs: [prepare, ci-gate, codeql-gate] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v5.0.0 + with: + dotnet-version: | + 9.0.x + 10.0.x + - name: Stamp version from tag + shell: bash + run: python3 tools/shipwright-version-stamp.py --tag "v${{ needs.prepare.outputs.version }}" --root . - name: Pack solution - env: - VERSION: ${{ steps.version.outputs.VERSION }} - run: | - set -euo pipefail - dotnet pack DataProvider.sln -c Release \ - -p:Version="$VERSION" \ - -p:PackageVersion="$VERSION" \ - -o ./nupkgs - + shell: bash + run: dotnet pack DataProvider.sln -c Release -o ./nupkgs - name: Push packages to NuGet + shell: bash env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | set -euo pipefail if [ -z "${NUGET_API_KEY:-}" ]; then - echo "NUGET_API_KEY secret is not set." >&2 + echo "::error::NUGET_API_KEY secret is not set." >&2 exit 1 fi shopt -s nullglob packages=(./nupkgs/*.nupkg) if [ ${#packages[@]} -eq 0 ]; then - echo "No packages produced — failing." >&2 + echo "::error::No packages produced — failing." >&2 exit 1 fi for pkg in "${packages[@]}"; do - echo "::group::Push $pkg" dotnet nuget push "$pkg" \ --api-key "$NUGET_API_KEY" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate - echo "::endgroup::" done - - name: Update version in Directory.Build.props + # ── Per-platform binaries → GitHub Release ──────────────────────────────── + release-binaries: + needs: [prepare, ci-gate, codeql-gate] + uses: ./.github/workflows/release-binary-multiplatform.yml + with: + binary_name: lql-lsp + version: ${{ needs.prepare.outputs.version }} + permissions: + contents: write + id-token: write + attestations: write + + # ── Per-platform .NET Native AOT migration CLI → GitHub Release [MIG-AOT-CI] ─ + release-dotnet-binaries: + needs: [prepare, ci-gate, codeql-gate] + uses: ./.github/workflows/release-dotnet-aot-multiplatform.yml + with: + binary_name: DataProviderMigrate + version: ${{ needs.prepare.outputs.version }} + permissions: + contents: write + id-token: write + attestations: write + + # ── Extract per-asset checksums from the signed SHA256SUMS ───────────────── + checksums: + needs: [prepare, release-binaries] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + darwin_arm64: ${{ steps.x.outputs.darwin_arm64 }} + win32_x64: ${{ steps.x.outputs.win32_x64 }} + steps: + - name: Fetch and parse SHA256SUMS from the release + id: x + shell: bash env: - VERSION: ${{ steps.version.outputs.VERSION }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail - # Tag-triggered runs check out a detached HEAD. Find the unique remote - # branch that contains the tagged commit so we know where to push the - # version-bump back to. If 0 or >1 branches contain it, fail loudly — - # we will not guess. - git fetch --no-tags --prune origin '+refs/heads/*:refs/remotes/origin/*' - mapfile -t BRANCHES < <(git branch -r --contains HEAD --format='%(refname:short)' \ - | sed 's|^origin/||' \ - | grep -vx 'HEAD' \ - | sort -u) - if [ ${#BRANCHES[@]} -eq 0 ]; then - echo "No remote branch contains the tagged commit. Refusing to push." >&2 - exit 1 - fi - if [ ${#BRANCHES[@]} -gt 1 ]; then - echo "Tagged commit belongs to multiple branches: ${BRANCHES[*]}. Refusing to guess." >&2 - exit 1 - fi - TARGET_BRANCH="${BRANCHES[0]}" - echo "Pushing version bump to origin/$TARGET_BRANCH" - sed -i 's|.*|'"$VERSION"'|' Directory.Build.props - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Directory.Build.props - git diff --cached --quiet || git commit -m "chore: bump version to $VERSION [skip ci]" - git push origin "HEAD:refs/heads/$TARGET_BRANCH" - - - name: Create GitHub Release + gh release download "v${VERSION}" --repo "${OWNER_REPO}" --pattern SHA256SUMS --dir . + darwin="$(grep -E "lql-lsp-${VERSION}-darwin-arm64\.tar\.gz$" SHA256SUMS | awk '{print $1}')" + win="$(grep -E "lql-lsp-${VERSION}-win32-x64\.zip$" SHA256SUMS | awk '{print $1}')" + test -n "$darwin" && test -n "$win" + echo "darwin_arm64=${darwin}" >> "$GITHUB_OUTPUT" + echo "win32_x64=${win}" >> "$GITHUB_OUTPUT" + + # ── Homebrew tap ────────────────────────────────────────────────────────── + publish-brew: + needs: [prepare, checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-brew-tap.yml + with: + tap_repo: Nimblesite/homebrew-lql + formula_name: lql-lsp + version: ${{ needs.prepare.outputs.version }} + asset_url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/lql-lsp-${{ needs.prepare.outputs.version }}-darwin-arm64.tar.gz + sha256: ${{ needs.checksums.outputs.darwin_arm64 }} + binary_name: lql-lsp + homepage: https://github.com/Nimblesite/DataProvider + description: Lambda Query Language LSP server + secrets: + tap_token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + # ── Scoop bucket ────────────────────────────────────────────────────────── + publish-scoop: + needs: [prepare, checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-scoop-bucket.yml + with: + bucket_repo: Nimblesite/scoop-lql + manifest_name: lql-lsp + version: ${{ needs.prepare.outputs.version }} + url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/lql-lsp-${{ needs.prepare.outputs.version }}-win32-x64.zip + sha256: ${{ needs.checksums.outputs.win32_x64 }} + bin: lql-lsp.exe + homepage: https://github.com/Nimblesite/DataProvider + description: Lambda Query Language LSP server + secrets: + bucket_token: ${{ secrets.SCOOP_BUCKET_TOKEN }} + + # ── Migration CLI checksums (from its own signed SHA256SUMS) [MIG-AOT-CI] ── + dotnet-checksums: + needs: [prepare, release-dotnet-binaries] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + darwin_arm64: ${{ steps.x.outputs.darwin_arm64 }} + win32_x64: ${{ steps.x.outputs.win32_x64 }} + steps: + - name: Fetch and parse DataProviderMigrate-SHA256SUMS from the release + id: x + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "${{ github.ref_name }}" --generate-notes + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + gh release download "v${VERSION}" --repo "${OWNER_REPO}" --pattern DataProviderMigrate-SHA256SUMS --dir . + darwin="$(grep -E "DataProviderMigrate-${VERSION}-darwin-arm64\.tar\.gz$" DataProviderMigrate-SHA256SUMS | awk '{print $1}')" + win="$(grep -E "DataProviderMigrate-${VERSION}-win32-x64\.zip$" DataProviderMigrate-SHA256SUMS | awk '{print $1}')" + test -n "$darwin" && test -n "$win" + echo "darwin_arm64=${darwin}" >> "$GITHUB_OUTPUT" + echo "win32_x64=${win}" >> "$GITHUB_OUTPUT" + + # ── Homebrew tap (migration CLI) ────────────────────────────────────────── + publish-brew-migrate: + needs: [prepare, dotnet-checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-brew-tap.yml + with: + tap_repo: Nimblesite/homebrew-lql + formula_name: dataprovider-migrate + version: ${{ needs.prepare.outputs.version }} + asset_url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/DataProviderMigrate-${{ needs.prepare.outputs.version }}-darwin-arm64.tar.gz + sha256: ${{ needs.dotnet-checksums.outputs.darwin_arm64 }} + binary_name: DataProviderMigrate + homepage: https://github.com/Nimblesite/DataProvider + description: DataProvider database schema migration CLI (YAML → SQLite/PostgreSQL) + secrets: + tap_token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + # ── Scoop bucket (migration CLI) ────────────────────────────────────────── + publish-scoop-migrate: + needs: [prepare, dotnet-checksums] + if: needs.prepare.outputs.prerelease == 'false' + uses: ./.github/workflows/publish-scoop-bucket.yml + with: + bucket_repo: Nimblesite/scoop-lql + manifest_name: dataprovider-migrate + version: ${{ needs.prepare.outputs.version }} + url: https://github.com/Nimblesite/DataProvider/releases/download/v${{ needs.prepare.outputs.version }}/DataProviderMigrate-${{ needs.prepare.outputs.version }}-win32-x64.zip + sha256: ${{ needs.dotnet-checksums.outputs.win32_x64 }} + bin: DataProviderMigrate.exe + homepage: https://github.com/Nimblesite/DataProvider + description: DataProvider database schema migration CLI (YAML → SQLite/PostgreSQL) + secrets: + bucket_token: ${{ secrets.SCOOP_BUCKET_TOKEN }} + + # ── Per-platform VSIX → VS Code Marketplace (Entra OIDC, no PAT) ─────────── + publish-vsix: + needs: [prepare, ci-gate, codeql-gate, release-binaries] + uses: ./.github/workflows/publish-vsix-per-platform.yml + with: + version: ${{ needs.prepare.outputs.version }} + pre_release: ${{ needs.prepare.outputs.prerelease == 'true' }} + marketplace_environment: release + secrets: + azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} + permissions: + contents: read + id-token: write + attestations: write + # ── Documentation sites (both deploy on release) ────────────────────────── deploy-lql-website: - needs: publish + needs: [prepare, publish-nuget] uses: ./.github/workflows/deploy-lql-website.yml with: - version: ${{ needs.publish.outputs.version }} + version: ${{ needs.prepare.outputs.version }} permissions: contents: read pages: write id-token: write deploy-website: - needs: [publish, deploy-lql-website] + needs: [prepare, publish-nuget, deploy-lql-website] uses: ./.github/workflows/deploy-pages.yml with: - version: ${{ needs.publish.outputs.version }} + version: ${{ needs.prepare.outputs.version }} secrets: inherit permissions: contents: read diff --git a/.gitignore b/.gitignore index 3f440c93..1ff75667 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ +# agent-pmo:795a9c2 # ═══════════════════════════════════════════════════════════════════════════════ # Universal (portfolio-wide) # ═══════════════════════════════════════════════════════════════════════════════ +# AI agent + IDE config dirs are COMMITTED, never ignored ([GITIGNORE-RULES]): +# .vscode/ .idea/ .claude/ .codex/ .agents/ .cline/ .clinerules/ .opencode/ +# .cursorrules .windsurfrules .github/copilot-instructions.md AGENTS.md CLAUDE.md +# Force-commit Claude's local settings (auto-ignored by Claude Code otherwise): +!.claude/settings.local.json .DS_Store? ._* .Spotlight-V100 @@ -150,7 +156,8 @@ _Pvt_Extensions # ═══════════════════════════════════════════════════════════════════════════════ # JetBrains Rider # ═══════════════════════════════════════════════════════════════════════════════ -.idea/ +# NOTE: .idea/ is intentionally NOT ignored — shared run configs + code-style +# settings are committed per [GITIGNORE-RULES]. *.sln.iml # ═══════════════════════════════════════════════════════════════════════════════ @@ -443,13 +450,6 @@ Generated_Code/ # Build timestamps .timestamp -# Dashboard vendor JS (downloaded at build time) -Samples/Dashboard/Dashboard.Web/wwwroot/js/vendor/ - -# Dashboard compiled JS (H5/Bridge output) -Samples/Dashboard/Dashboard.Web/wwwroot/js/Dashboard.js -Samples/Dashboard/Dashboard.Web/wwwroot/js/Dashboard.min.js -Samples/Dashboard/Dashboard.Web/wwwroot/js/index.html Website/_site/ Website/docfx/api/ @@ -466,14 +466,12 @@ Website/src/docs/migrations.md # Images copied from repo at build time Website/src/assets/images/lqldbbrowser.png -Website/src/assets/images/Samples/ Lql/Lql.TypeProvider.FSharp.Tests.Data/Generated/ *.nupkg *.snupkg -Samples/ICD10CM/.venv .playwright-mcp/ @@ -498,3 +496,7 @@ Reporting/Nimblesite.Reporting.React/wwwroot/js/h5.meta.js Reporting/Nimblesite.Reporting.React/wwwroot/js/h5.min.js Reporting/Nimblesite.Reporting.React/wwwroot/js/h5.meta.min.js Reporting/Nimblesite.Reporting.React/wwwroot/js/index.html + + +.deslop-cache/ +.deslop/ \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..c0e2ef43 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "too-many-cooks": { + "url": "http://localhost:4040/mcp" + } + } +} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5ededd52..b63716e8 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,9 @@ { - "_agent_pmo": "74cf183", + "_agent_pmo": "795a9c2", "recommendations": [ "nimblesite.commandtree", "nimblesite.too-many-cooks", - "nimblesite.typeDiagram" + "nimblesite.typeDiagram", + "nimblesite.napper" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 26577f38..d8f54d8d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,14 @@ "csharp.maxProjectFileCountForDiagnosticAnalysis": 10000, "dotnet.backgroundAnalysis.analyzerDiagnosticsScope": "fullSolution", "dotnet.backgroundAnalysis.compilerDiagnosticsScope": "fullSolution", + "files.watcherExclude": { + "**/bin/**": true, + "**/obj/**": true, + "**/target/**": true, + "**/node_modules/**": true, + "**/TestResults/**": true, + "**/.deslop/cache/**": true + }, "code-runner.clearPreviousOutput": true, "code-runner.saveFileBeforeRun": true, "code-runner.runInTerminal": true, @@ -15,5 +23,6 @@ "titleBar.activeForeground": "#F4F7FB", "titleBar.inactiveBackground": "#070D1F", "titleBar.inactiveForeground": "#F4F7FBcc" - } -} \ No newline at end of file + }, + "deslop.topOffenders.splitByLanguage": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 194edb6c..dbed3d49 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,14 +1,23 @@ { "version": "2.0.0", + "options": { + "cwd": "${workspaceFolder}", + "env": { + "DOTNET_PROCESSOR_COUNT": "2", + "DOTNET_GCHeapHardLimit": "0x80000000", + "DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER": "1", + "CARGO_BUILD_JOBS": "2" + } + }, "tasks": [ // ═══════════════════════════════════════════════════════════════ // .NET BUILD // ═══════════════════════════════════════════════════════════════ { "label": "Build: Solution", - "command": "dotnet", + "command": "make", "type": "process", - "args": ["build", "${workspaceFolder}/DataProvider.sln", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary;ForceNoAlign"], + "args": ["build"], "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -19,7 +28,7 @@ "label": "Build: Publish Solution", "command": "dotnet", "type": "process", - "args": ["publish", "${workspaceFolder}/DataProvider.sln", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary;ForceNoAlign"], + "args": ["publish", "${workspaceFolder}/DataProvider.sln", "--disable-build-servers", "-m:1", "-p:BuildInParallel=false", "-p:UseSharedCompilation=false", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary;ForceNoAlign"], "problemMatcher": "$msCompile" }, { @@ -136,7 +145,7 @@ { "label": "Test: All", "type": "shell", - "command": "dotnet test ${workspaceFolder}/DataProvider.sln", + "command": "make test", "problemMatcher": "$msCompile", "group": { "kind": "test", diff --git a/AGENTS.md b/AGENTS.md index a8dd7ab9..ddbebb24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ - + # Agent Instructions @CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 73dad95e..399a4e6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,13 +7,13 @@ ⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ - + ## Project Overview DataProvider is a comprehensive .NET database access toolkit: source generation for SQL extension methods, the Lambda Query Language (LQL) transpiler, bidirectional offline-first sync, WebAuthn + RBAC auth, and an embeddable reporting platform. The LQL LSP is implemented in Rust with a VS Code extension in TypeScript. Healthcare sample applications live in a separate repo: [Nimblesite/ClinicalCoding](https://github.com/Nimblesite/ClinicalCoding). -**Primary language(s):** C# (.NET 10.0), Rust, TypeScript, F# +**Primary language(s):** C#, Rust, TypeScript, F# **Build command:** `make ci` **Test command:** `make test` **Lint command:** `make lint` @@ -30,11 +30,42 @@ If the TMC server is available: 5. Release locks immediately when done 6. Never edit a locked file — wait or find another approach +## Autonomy + +- **Act autonomously. Do NOT stop to ask the user questions.** When something is ambiguous, choose the most reasonable default, record the assumption, and continue to completion. +- **No mid-task pauses for confirmation, clarification, or approval.** Deliver finished work plus a short summary of any assumptions made. +- **Auto-memory is OFF.** All persistent rules go through a reviewed PR to this file — never auto-captured memory. (Claude Code: `"autoMemoryEnabled": false` in committed `.claude/settings.local.json`.) + +## Git discipline — only when you've been given the green light to use git + +- **Default to NOT touching git at all.** Use git only when the user has explicitly green-lit it for the task. Absent that, leave commits, branches, pushes, and merges to the user and CI. +- **NEVER push to the default branch (`main`) directly.** Always PR → CI green → merge. No exceptions. +- **Once you open a PR, OWN it until it's green.** Enable auto-merge where allowed (`gh pr merge --auto --squash`) so it lands when checks pass — then keep monitoring: on a failure, pull the logs, fix it, push, and loop until every required check passes. Never hand back a red or still-running PR. +- **NEVER list yourself (the agent) as a commit co-author.** No `Co-Authored-By` trailer, no agent attribution. This is never overridable. +- **Work on exactly ONE branch at a time, always** — even with multiple agents working concurrently (coordinate via TMC). Reuse the open feature branch. +- **NEVER start a new branch when a feature branch already exists.** Check first; work on the open one. If multiple feature branches exist, merge them into one IMMEDIATELY before doing any other work. +- **Worktrees are forbidden.** All agents work in this checkout on the same open feature branch. +- **Codex subagents are forbidden.** Delegate parallel code work only to Pi. Start every Pi agent in this checkout, verify its branch before it edits, and assign nonoverlapping files. Never create a branch or worktree for a Pi agent. + +## Duplication — Deslop (MANDATORY) + +Spec: `[CI-DESLOP]`. Read the [docs](https://deslop.live/docs/for-ai/). False positives → log an issue with [Deslop](https://github.com/Nimblesite/Deslop/issues). Deslop earns its keep through **prevention, not cleanup.** Use its MCP tools on every code change: + +- **BEFORE you author** any function, method, class, helper, fixture, or test setup → call the **`find-similar`** MCP tool. + - `signals.fused ≥ 0.85`, or an `identical` / `nearly_identical` bucket → **REUSE the existing code. Do NOT write a duplicate.** + - `0.6 ≤ fused < 0.85` → open the canonical occurrence and bias hard toward reusing/extending it. + - `fused < 0.6` or empty → proceed and write the new code. +- **AFTER you change code** → call **`rescan`**, then **`top-offenders`** (worst clusters by severity) and **`cluster-by-id`** (full members + signals for a cluster you intend to merge). Use **`report-for-file`** / **`report-for-range`** to inspect a specific file or selection. Call **`schema-doc`** once per session to learn the report shape. +- **NEVER game the gate.** Do not silence findings by widening `max_duplication_percent`, marking code `hidden`, or splitting it into trivially different shapes. + +The duplication budget lives in committed `.deslop.toml` (`max_duplication_percent`). CI runs `deslop .` and the build **TANKS** (exit 3) if duplication exceeds it. The threshold ratchets **DOWN only** — lower it in the same PR when you reduce duplication; never raise it without written justification. + ## Hard Rules — Universal (no exceptions) - **Parsing SQL with anything other than the ⭐️ OFFICIAL 👨🏼‍⚖️ ⭐️ platform specific parser = ⛔️ILLEGAL** - Use the actual .NET parser specified by the DB maintainer - **DO NOT use git or Docker commands.** No `git add`, `git commit`, `git push`, or any git/Docker command. CI and GitHub Actions handle these. -- **ZERO DUPLICATION.** Before writing any code, search the codebase for existing implementations. Move code, don't copy it. +- **ZERO DUPLICATION.** Before writing any code, search the codebase for existing implementations. Move code, don't copy it. Run the Deslop MCP tools before AND after every change — see [Duplication — Deslop](#duplication--deslop-mandatory). +- **DATA MODELS — generate with typeDiagram, NEVER by hand.** Define every data model (domain types, DTOs, entities, enums, ADTs) in [typeDiagram](https://typediagram.dev/docs/) and generate the language code from it. The committed model is the source of truth; `make build` regenerates the types — never edit generated files, never hand-craft a model. If typeDiagram can't express a case, file an issue on its repo and reference it at a minimal temporary shim. Implements `[MODEL-TYPEDIAGRAM]`. - **NEVER THROW** — Return `Result`. Wrap failures in try/catch - **No casting/!** — Pattern match on type only - **No suppressing warnings** — Illegal. Fix the code, not the linter. @@ -105,7 +136,12 @@ public abstract partial record Result { private Result() { } Always include these in `Directory.Build.props`: - `Microsoft.CodeAnalysis.NetAnalyzers` — .NET analyzers - `Outcome` — Result types for Railway Oriented Programming -- `Exhaustion` — Exhaustive pattern matching analyzer +- `Exhaustion` 1.0.1 or later — bounded exhaustive switch analysis + +Do not downgrade `Exhaustion` to 1.0.0: its recursive AST analysis can exhaust +compiler memory ([upstream issue #146](https://github.com/MelbourneDeveloper/RestClient.Net/issues/146), fixed in 1.0.1). +Keep the .NET analyzers and warnings enabled. Use `make` for bounded build/test +execution; direct commands must also limit the .NET GC heap and build concurrency. ### Rust @@ -131,9 +167,6 @@ Always include these in `Directory.Build.props`: - LQL is database platform INDEPENDENT. It MUST work exactly the same on whatever platform it is transpiled to. Failure for this to happen must be logged as a GitHub issue -## LQL -- LQL is database platform INDEPENDENT. It MUST work exactly the same on whatever platform it is transpiled to. Failure for this to happen must be logged as a GitHub issue - ## CSS - **MINIMAL CSS** — Do not duplicate CSS classes @@ -196,7 +229,6 @@ make vsix # build LSP + package VS Code extension | Migration | `Migration/` | Schema migration framework (YAML -> SQL DDL) | | Sync | `Sync/` | Offline-first bidirectional sync | | Gatekeeper | `Gatekeeper/` | WebAuthn + RBAC auth | -| Samples | `Samples/` | Clinical, Scheduling, ICD10, Dashboard | | Reporting | `Reporting/` | Embeddable reporting platform (SQL/LQL data sources, JSON config, React renderer) | | Website | `Website/` | Documentation site (Eleventy + DocFX) | @@ -215,7 +247,6 @@ DataProvider/ ├── Migration/ # Schema migration framework ├── Sync/ # Bidirectional sync engine ├── Gatekeeper/ # WebAuthn auth + RBAC -├── Samples/ # Healthcare samples ├── Reporting/ # Embeddable reporting platform ├── Website/ # Documentation site ├── docs/ @@ -228,7 +259,7 @@ DataProvider/ ## Config -- .NET 10.0, C# latest, nullable, warnings as errors +- .NET, C# latest, nullable, warnings as errors - Central config in `Directory.Build.props` - Format: `dotnet csharpier .` diff --git a/DataProvider.sln b/DataProvider.sln index b51fb3a1..a2b2d000 100644 --- a/DataProvider.sln +++ b/DataProvider.sln @@ -81,12 +81,6 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Nimblesite.Lql.TypeProvider EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.TypeProvider.FSharp.Tests.Data", "Lql\Nimblesite.Lql.TypeProvider.FSharp.Tests.Data\Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj", "{0D6A831B-4759-46F2-8527-51C8A9CB6F6F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.SqlServer", "DataProvider\Nimblesite.DataProvider.SqlServer\Nimblesite.DataProvider.SqlServer.csproj", "{238A9928-E501-46AD-963B-BB529A983D3E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Reporting", "Reporting", "{7E3B463F-4C55-B4D7-FA79-ADF7D77E220F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Reporting.Engine", "Reporting\Nimblesite.Reporting.Engine\Nimblesite.Reporting.Engine.csproj", "{3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataProvider", "DataProvider", "{6C7402B1-8968-47EB-8CE8-51768E9CAD79}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Reporting.Api", "Reporting\Nimblesite.Reporting.Api\Nimblesite.Reporting.Api.csproj", "{1B908378-2196-4396-AFB4-C2FA3E4D71F7}" @@ -103,10 +97,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Pos EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Parsing.Tests", "DataProvider\Nimblesite.DataProvider.Parsing.Tests\Nimblesite.DataProvider.Parsing.Tests.csproj", "{8E38A258-EA12-40DD-9268-9A6047DB2368}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lql", "Lql", "{54B846BA-A27D-B76F-8730-402A5742FF43}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lql", "Lql\Lql\Lql.csproj", "{0309F6C0-C462-49D6-83D5-7C4B93066AD3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migration", "Migration", "{C7F49633-8D5E-7E19-1580-A6459B2EAE66}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Migration.SqlServer", "Migration\Nimblesite.DataProvider.Migration.SqlServer\Nimblesite.DataProvider.Migration.SqlServer.csproj", "{D16BF05C-1614-442E-972F-4208ADA0FF5C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -525,30 +521,6 @@ Global {0D6A831B-4759-46F2-8527-51C8A9CB6F6F}.Release|x64.Build.0 = Release|Any CPU {0D6A831B-4759-46F2-8527-51C8A9CB6F6F}.Release|x86.ActiveCfg = Release|Any CPU {0D6A831B-4759-46F2-8527-51C8A9CB6F6F}.Release|x86.Build.0 = Release|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Debug|x64.ActiveCfg = Debug|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Debug|x64.Build.0 = Debug|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Debug|x86.ActiveCfg = Debug|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Debug|x86.Build.0 = Debug|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Release|Any CPU.Build.0 = Release|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Release|x64.ActiveCfg = Release|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Release|x64.Build.0 = Release|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Release|x86.ActiveCfg = Release|Any CPU - {238A9928-E501-46AD-963B-BB529A983D3E}.Release|x86.Build.0 = Release|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x64.ActiveCfg = Debug|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x64.Build.0 = Debug|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x86.ActiveCfg = Debug|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x86.Build.0 = Debug|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|Any CPU.Build.0 = Release|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x64.ActiveCfg = Release|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x64.Build.0 = Release|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x86.ActiveCfg = Release|Any CPU - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x86.Build.0 = Release|Any CPU {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|Any CPU.Build.0 = Debug|Any CPU {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -645,6 +617,18 @@ Global {0309F6C0-C462-49D6-83D5-7C4B93066AD3}.Release|x64.Build.0 = Release|Any CPU {0309F6C0-C462-49D6-83D5-7C4B93066AD3}.Release|x86.ActiveCfg = Release|Any CPU {0309F6C0-C462-49D6-83D5-7C4B93066AD3}.Release|x86.Build.0 = Release|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Debug|x64.ActiveCfg = Debug|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Debug|x64.Build.0 = Debug|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Debug|x86.ActiveCfg = Debug|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Debug|x86.Build.0 = Debug|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Release|Any CPU.Build.0 = Release|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Release|x64.ActiveCfg = Release|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Release|x64.Build.0 = Release|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Release|x86.ActiveCfg = Release|Any CPU + {D16BF05C-1614-442E-972F-4208ADA0FF5C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -684,16 +668,11 @@ Global {6AB2EA96-4A75-49DB-AC65-B247BBFAE9A3} = {54B846BA-A27D-B76F-8730-402A5742FF43} {B0104C42-1B46-4CA5-9E91-A5F09D7E5B92} = {54B846BA-A27D-B76F-8730-402A5742FF43} {0D6A831B-4759-46F2-8527-51C8A9CB6F6F} = {54B846BA-A27D-B76F-8730-402A5742FF43} - {238A9928-E501-46AD-963B-BB529A983D3E} = {43BAF0A3-C050-BE83-B489-7FC6F9FDE235} - {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} - {1B908378-2196-4396-AFB4-C2FA3E4D71F7} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} - {99C7B597-0B71-429C-89F8-A2AF6727D5B2} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} - {6BB769C2-4164-4781-A68F-516172A048F1} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} - {79CF7074-2442-402D-8E17-E7B222B00200} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} {A8E3E98F-3705-4AC7-BF59-ACD2D90C7504} = {6C7402B1-8968-47EB-8CE8-51768E9CAD79} {844487F2-1F62-4EAA-AE15-0E2A73C5EC96} = {6C7402B1-8968-47EB-8CE8-51768E9CAD79} {8E38A258-EA12-40DD-9268-9A6047DB2368} = {6C7402B1-8968-47EB-8CE8-51768E9CAD79} {0309F6C0-C462-49D6-83D5-7C4B93066AD3} = {54B846BA-A27D-B76F-8730-402A5742FF43} + {D16BF05C-1614-442E-972F-4208ADA0FF5C} = {C7F49633-8D5E-7E19-1580-A6459B2EAE66} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {53128A75-E7B6-4B83-B079-A309FCC2AD9C} diff --git a/DataProvider/DataProvider/PostgresCli.cs b/DataProvider/DataProvider/PostgresCli.cs index 94468f8d..3ea7770d 100644 --- a/DataProvider/DataProvider/PostgresCli.cs +++ b/DataProvider/DataProvider/PostgresCli.cs @@ -466,7 +466,7 @@ private static (string name, string sqlType) ParseColumnDefinition( if (colRef.Contains('.')) { // BUG4 fix: split on dot but only outside quoted segments. A - // qualified ref like "fhir_Patient"."Active" must split on the + // qualified ref like "MixedTable"."Active" must split on the // middle dot, not the dots inside quoted parts (there aren't // any here, but the simple Split is fine). Then strip the // surrounding `"` chars from the tail. @@ -595,81 +595,8 @@ string outDir ); } - if (columns.Count == 0) - { - return new Result.Error( - new SqlError($"No columns found for table {table.Schema}.{table.Name}") - ); - } - - var sb = new StringBuilder(); - // Use the literal table name (e.g. `gk_user`) instead of - // PascalCasing it. The SQLite.Cli emits methods like - // `Insertgk_userAsync`, and consumers reference them by that - // exact name. PascalCasing here breaks consumer call sites. - var pascalName = table.Name; - - // Header - _ = sb.AppendLine("// "); - _ = sb.AppendLine("#nullable enable"); - _ = sb.AppendLine(); - _ = sb.AppendLine("using Npgsql;"); - _ = sb.AppendLine("using Outcome;"); - _ = sb.AppendLine("using Nimblesite.Sql.Model;"); - _ = sb.AppendLine(); - _ = sb.AppendLine("namespace Generated;"); - _ = sb.AppendLine(); - - // Extension class - _ = sb.AppendLine("/// "); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $"/// Generated CRUD operations for {table.Name} table." - ); - _ = sb.AppendLine("/// "); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $"public static class {pascalName}Extensions" - ); - _ = sb.AppendLine("{"); - - // Generate INSERT method - if (table.GenerateInsert) - { - GenerateInsertMethod(sb, table, columns, pascalName); - } - - // Generate UPDATE method - if (table.GenerateUpdate) - { - GenerateUpdateMethod(sb, table, columns, pascalName); - } - - // Generate DELETE method - if (table.GenerateDelete) - { - GenerateDeleteMethod(sb, table, columns, pascalName); - } - - // Generate bulk INSERT method - if (table.GenerateBulkInsert) - { - GenerateBulkInsertMethod(sb, table, columns, pascalName); - } - - // Generate bulk UPSERT method - if (table.GenerateBulkUpsert) - { - GenerateBulkUpsertMethod(sb, table, columns, pascalName); - } - - _ = sb.AppendLine("}"); - - var target = Path.Combine(outDir, $"{pascalName}Operations.g.cs"); - await File.WriteAllTextAsync(target, sb.ToString()).ConfigureAwait(false); - Console.WriteLine($"✅ Generated {target}"); - - return new Result.Ok(sb.ToString()); + return await AssembleAndWriteTableOperationsAsync(table, columns, outDir) + .ConfigureAwait(false); } private static async Task> GenerateTableOperationsAsync( @@ -736,6 +663,16 @@ ORDER BY c.ordinal_position } } + return await AssembleAndWriteTableOperationsAsync(table, columns, outDir) + .ConfigureAwait(false); + } + + private static async Task> AssembleAndWriteTableOperationsAsync( + TableConfigItem table, + List columns, + string outDir + ) + { if (columns.Count == 0) { return new Result.Error( @@ -750,6 +687,23 @@ ORDER BY c.ordinal_position // exact name. PascalCasing here breaks consumer call sites. var pascalName = table.Name; + AppendExtensionsHeader(sb, table, pascalName); + AppendCrudMethods(sb, table, columns, pascalName); + _ = sb.AppendLine("}"); + + var target = Path.Combine(outDir, $"{pascalName}Operations.g.cs"); + await File.WriteAllTextAsync(target, sb.ToString()).ConfigureAwait(false); + Console.WriteLine($"✅ Generated {target}"); + + return new Result.Ok(sb.ToString()); + } + + private static void AppendExtensionsHeader( + StringBuilder sb, + TableConfigItem table, + string pascalName + ) + { // Header _ = sb.AppendLine("// "); _ = sb.AppendLine("#nullable enable"); @@ -773,7 +727,15 @@ ORDER BY c.ordinal_position $"public static class {pascalName}Extensions" ); _ = sb.AppendLine("{"); + } + private static void AppendCrudMethods( + StringBuilder sb, + TableConfigItem table, + List columns, + string pascalName + ) + { // Generate INSERT method if (table.GenerateInsert) { @@ -803,14 +765,6 @@ ORDER BY c.ordinal_position { GenerateBulkUpsertMethod(sb, table, columns, pascalName); } - - _ = sb.AppendLine("}"); - - var target = Path.Combine(outDir, $"{pascalName}Operations.g.cs"); - await File.WriteAllTextAsync(target, sb.ToString()).ConfigureAwait(false); - Console.WriteLine($"✅ Generated {target}"); - - return new Result.Ok(sb.ToString()); } private static void GenerateInsertMethod( @@ -845,68 +799,15 @@ string pascalName _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); _ = sb.AppendLine(" {"); - // BUG3 fix: quote column + table identifiers. The sql string is a C# - // verbatim (@") literal, so "" represents a single " in the emitted - // SQL. Wrap every ident in "" so mixed-case tables/columns survive PG - // case-folding. - var colNames = string.Join(", ", insertable.Select(c => $"\"\"{c.Name}\"\"")); - var paramNames = string.Join(", ", insertable.Select(c => $"@{c.Name}")); - // BUG7 fix: RETURNING clause must reference the actual primary-key - // column name, quoted for case-folding survival. The previous hard- - // coded `RETURNING id` failed at runtime on tables whose PK column is - // PascalCase (e.g. "Id") with `column "id" does not exist`. - var returningClause = - table.PrimaryKeyColumns.Count > 0 - ? $"RETURNING \"\"{table.PrimaryKeyColumns[0]}\"\"" - : "RETURNING 1"; - - _ = sb.AppendLine(" const string sql = @\""); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" INSERT INTO \"\"{table.Schema}\"\".\"\"{table.Name}\"\" ({colNames})" - ); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" VALUES ({paramNames})"); - _ = sb.AppendLine(" ON CONFLICT DO NOTHING"); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {returningClause}\";"); + EmitInsertSql(sb, table, insertable); _ = sb.AppendLine(); _ = sb.AppendLine(" try"); _ = sb.AppendLine(" {"); _ = sb.AppendLine(" await using var cmd = new NpgsqlCommand(sql, conn);"); - foreach (var col in insertable) - { - var paramName = col.Name; - if (col.IsNullable) - { - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{paramName}\", (object?){paramName} ?? DBNull.Value);" - ); - } - else - { - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{paramName}\", {paramName});" - ); - } - } + EmitParameterBindings(sb, insertable); - _ = sb.AppendLine(); - _ = sb.AppendLine( - " var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);" - ); - _ = sb.AppendLine( - " return new Result.Ok(result is Guid g ? g : null);" - ); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" catch (Exception ex)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine( - " return new Result.Error(SqlError.FromException(ex));" - ); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" }"); + EmitInsertResult(sb); // Bug #16: also emit an IDbTransaction overload that delegates to // the same SQL via a NpgsqlCommand bound to the transaction's @@ -927,16 +828,6 @@ string pascalName ) { var parameters = string.Join(", ", insertable.Select(c => $"{c.CSharpType} {c.Name}")); - // BUG3 fix: quote idents in transaction overload INSERT, same reason - // as primary Insert method above. - var colNames = string.Join(", ", insertable.Select(c => $"\"\"{c.Name}\"\"")); - var paramNames = string.Join(", ", insertable.Select(c => $"@{c.Name}")); - // BUG7 fix: same RETURNING fix as the NpgsqlConnection overload. - var returningClause = - table.PrimaryKeyColumns.Count > 0 - ? $"RETURNING \"\"{table.PrimaryKeyColumns[0]}\"\"" - : "RETURNING 1"; - _ = sb.AppendLine(); _ = sb.AppendLine(" /// "); _ = sb.AppendLine( @@ -951,14 +842,7 @@ string pascalName _ = sb.AppendLine(" this System.Data.IDbTransaction transaction,"); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" const string sql = @\""); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" INSERT INTO \"\"{table.Schema}\"\".\"\"{table.Name}\"\" ({colNames})" - ); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" VALUES ({paramNames})"); - _ = sb.AppendLine(" ON CONFLICT DO NOTHING"); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {returningClause}\";"); + EmitInsertSql(sb, table, insertable); _ = sb.AppendLine(); _ = sb.AppendLine(" if (transaction.Connection is not NpgsqlConnection conn)"); _ = sb.AppendLine(" {"); @@ -972,24 +856,37 @@ string pascalName _ = sb.AppendLine( " await using var cmd = new NpgsqlCommand(sql, conn, (NpgsqlTransaction)transaction);" ); - foreach (var col in insertable) - { - var paramName = col.Name; - if (col.IsNullable) - { - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{paramName}\", (object?){paramName} ?? DBNull.Value);" - ); - } - else - { - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{paramName}\", {paramName});" - ); - } - } + EmitParameterBindings(sb, insertable); + EmitInsertResult(sb); + } + + private static void EmitInsertSql( + StringBuilder sb, + TableConfigItem table, + List insertable + ) + { + // The generated SQL is a C# verbatim string; doubled quotes preserve + // mixed-case identifiers. RETURNING must use the actual primary key. + var colNames = string.Join(", ", insertable.Select(c => $"\"\"{c.Name}\"\"")); + var paramNames = string.Join(", ", insertable.Select(c => $"@{c.Name}")); + var returningClause = + table.PrimaryKeyColumns.Count > 0 + ? $"RETURNING \"\"{table.PrimaryKeyColumns[0]}\"\"" + : "RETURNING 1"; + + _ = sb.AppendLine(" const string sql = @\""); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" INSERT INTO \"\"{table.Schema}\"\".\"\"{table.Name}\"\" ({colNames})" + ); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" VALUES ({paramNames})"); + _ = sb.AppendLine(" ON CONFLICT DO NOTHING"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {returningClause}\";"); + } + + private static void EmitInsertResult(StringBuilder sb) + { _ = sb.AppendLine(); _ = sb.AppendLine( " var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);" @@ -1052,7 +949,7 @@ string pascalName _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); _ = sb.AppendLine(" {"); _ = sb.AppendLine(" const string sql = @\""); - // BUG6 fix: quote schema + table in UPDATE so mixed-case fhir_Patient + // BUG6 fix: quote schema + table in UPDATE so mixed-case identifiers // round-trips through PG. Verbatim string -> use `""` escape form. _ = sb.AppendLine( CultureInfo.InvariantCulture, @@ -1285,16 +1182,42 @@ string pascalName insertable.Select(c => $"{c.CSharpType} {ToPascalCase(c.Name)}") ); + EmitBulkMethodPreamble(sb, table, pascalName, tupleType, upsert: false); + + // BUG3 fix: quote every identifier in emitted INSERT so mixed-case + // columns (Id, ChapterNumber) round-trip. Bare idents were folded to + // lowercase by PG and the statement failed with "column ... does not + // exist". Matches SELECT path which already quotes. + var colNames = string.Join(", ", insertable.Select(c => $"\\\"{c.Name}\\\"")); + EmitBulkValuesBuilder(sb, table, insertable, colNames); + _ = sb.AppendLine(" sql.Append(\" ON CONFLICT DO NOTHING\");"); + EmitBulkCommandExecution(sb, insertable); + } + + private static void EmitBulkMethodPreamble( + StringBuilder sb, + TableConfigItem table, + string pascalName, + string tupleType, + bool upsert + ) + { + var operation = upsert ? "Upsert" : "Insert"; + var operationLower = upsert ? "upsert" : "insert"; + var verb = upsert ? "upserts" : "inserts"; + var conflictDescription = upsert + ? "ON CONFLICT DO UPDATE to insert or update existing rows" + : "ON CONFLICT DO NOTHING to skip duplicates"; + var outcome = upsert ? "affected" : "inserted"; + var totalVariable = upsert ? "totalAffected" : "totalInserted"; + _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// "); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" /// Bulk inserts rows into {table.Name} using batched multi-row VALUES." - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" /// Uses ON CONFLICT DO NOTHING to skip duplicates." + $" /// Bulk {verb} rows into {table.Name} using batched multi-row VALUES." ); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// Uses {conflictDescription}."); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// "); _ = sb.AppendLine( CultureInfo.InvariantCulture, @@ -1302,7 +1225,7 @@ string pascalName ); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" /// Records to insert as tuples." + $" /// Records to {operationLower} as tuples." ); _ = sb.AppendLine( CultureInfo.InvariantCulture, @@ -1310,11 +1233,11 @@ string pascalName ); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" /// Total rows inserted." + $" /// Total rows {outcome}." ); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" public static async Task> BulkInsert{pascalName}Async(" + $" public static async Task> Bulk{operation}{pascalName}Async(" ); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" this NpgsqlConnection conn,"); _ = sb.AppendLine( @@ -1323,7 +1246,7 @@ string pascalName ); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" int batchSize = 1000)"); _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" var totalInserted = 0;"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" var {totalVariable} = 0;"); _ = sb.AppendLine( CultureInfo.InvariantCulture, $" var batch = new List<({tupleType})>(batchSize);" @@ -1338,14 +1261,14 @@ string pascalName _ = sb.AppendLine(" {"); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" var result = await ExecuteBulkInsert{pascalName}BatchAsync(conn, batch).ConfigureAwait(false);" + $" var result = await ExecuteBulk{operation}{pascalName}BatchAsync(conn, batch).ConfigureAwait(false);" ); _ = sb.AppendLine( " if (result is Result.Error err)" ); _ = sb.AppendLine(" return err;"); _ = sb.AppendLine( - " totalInserted += ((Result.Ok)result).Value;" + $" {totalVariable} += ((Result.Ok)result).Value;" ); _ = sb.AppendLine(" batch.Clear();"); _ = sb.AppendLine(" }"); @@ -1355,19 +1278,19 @@ string pascalName _ = sb.AppendLine(" {"); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" var finalResult = await ExecuteBulkInsert{pascalName}BatchAsync(conn, batch).ConfigureAwait(false);" + $" var finalResult = await ExecuteBulk{operation}{pascalName}BatchAsync(conn, batch).ConfigureAwait(false);" ); _ = sb.AppendLine( " if (finalResult is Result.Error finalErr)" ); _ = sb.AppendLine(" return finalErr;"); _ = sb.AppendLine( - " totalInserted += ((Result.Ok)finalResult).Value;" + $" {totalVariable} += ((Result.Ok)finalResult).Value;" ); _ = sb.AppendLine(" }"); _ = sb.AppendLine(); _ = sb.AppendLine( - " return new Result.Ok(totalInserted);" + $" return new Result.Ok({totalVariable});" ); _ = sb.AppendLine(" }"); _ = sb.AppendLine(" catch (Exception ex)"); @@ -1382,7 +1305,7 @@ string pascalName // Generate batch execution helper _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" private static async Task> ExecuteBulkInsert{pascalName}BatchAsync(" + $" private static async Task> ExecuteBulk{operation}{pascalName}BatchAsync(" ); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" NpgsqlConnection conn,"); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" List<({tupleType})> batch)"); @@ -1390,39 +1313,28 @@ string pascalName _ = sb.AppendLine(" if (batch.Count == 0)"); _ = sb.AppendLine(" return new Result.Ok(0);"); _ = sb.AppendLine(); + } - // BUG3 fix: quote every identifier in emitted INSERT so mixed-case - // columns (Id, ChapterNumber) round-trip. Bare idents were folded to - // lowercase by PG and the statement failed with "column ... does not - // exist". Matches SELECT path which already quotes. - var colNames = string.Join(", ", insertable.Select(c => $"\\\"{c.Name}\\\"")); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" var sql = new System.Text.StringBuilder(\"INSERT INTO \\\"{table.Schema}\\\".\\\"{table.Name}\\\" ({colNames}) VALUES \");" - ); - _ = sb.AppendLine(); - _ = sb.AppendLine(" for (int i = 0; i < batch.Count; i++)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" if (i > 0) sql.Append(\", \");"); - - // Build VALUES placeholders - var placeholders = string.Join( - ", ", - insertable.Select((c, idx) => $"@p\" + (i * {insertable.Count} + {idx}) + \"") - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" sql.Append(\"({placeholders})\");" - ); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" sql.Append(\" ON CONFLICT DO NOTHING\");"); + private static void EmitBulkCommandExecution(StringBuilder sb, List insertable) + { _ = sb.AppendLine(); _ = sb.AppendLine(" await using var cmd = new NpgsqlCommand(sql.ToString(), conn);"); _ = sb.AppendLine(); _ = sb.AppendLine(" for (int i = 0; i < batch.Count; i++)"); _ = sb.AppendLine(" {"); _ = sb.AppendLine(" var rec = batch[i];"); + EmitBulkParameterBindings(sb, insertable); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" + ); + _ = sb.AppendLine(" return new Result.Ok(rows);"); + _ = sb.AppendLine(" }"); + } + private static void EmitBulkParameterBindings(StringBuilder sb, List insertable) + { for (int i = 0; i < insertable.Count; i++) { var col = insertable[i]; @@ -1445,14 +1357,34 @@ string pascalName ); } } + } - _ = sb.AppendLine(" }"); + private static void EmitBulkValuesBuilder( + StringBuilder sb, + TableConfigItem table, + List insertable, + string colNames + ) + { + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" var sql = new System.Text.StringBuilder(\"INSERT INTO \\\"{table.Schema}\\\".\\\"{table.Name}\\\" ({colNames}) VALUES \");" + ); _ = sb.AppendLine(); + _ = sb.AppendLine(" for (int i = 0; i < batch.Count; i++)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" if (i > 0) sql.Append(\", \");"); + + // Build VALUES placeholders + var placeholders = string.Join( + ", ", + insertable.Select((c, idx) => $"@p\" + (i * {insertable.Count} + {idx}) + \"") + ); _ = sb.AppendLine( - " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" + CultureInfo.InvariantCulture, + $" sql.Append(\"({placeholders})\");" ); - _ = sb.AppendLine(" return new Result.Ok(rows);"); - _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); } private static void GenerateBulkUpsertMethod( @@ -1475,111 +1407,7 @@ string pascalName insertable.Select(c => $"{c.CSharpType} {ToPascalCase(c.Name)}") ); - _ = sb.AppendLine(); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// "); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" /// Bulk upserts rows into {table.Name} using batched multi-row VALUES." - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" /// Uses ON CONFLICT DO UPDATE to insert or update existing rows." - ); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// "); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" /// Open database connection." - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" /// Records to upsert as tuples." - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" /// Max rows per batch (default 1000)." - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" /// Total rows affected." - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" public static async Task> BulkUpsert{pascalName}Async(" - ); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" this NpgsqlConnection conn,"); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" IEnumerable<({tupleType})> records," - ); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" int batchSize = 1000)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" var totalAffected = 0;"); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" var batch = new List<({tupleType})>(batchSize);" - ); - _ = sb.AppendLine(); - _ = sb.AppendLine(" try"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" foreach (var record in records)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" batch.Add(record);"); - _ = sb.AppendLine(" if (batch.Count >= batchSize)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" var result = await ExecuteBulkUpsert{pascalName}BatchAsync(conn, batch).ConfigureAwait(false);" - ); - _ = sb.AppendLine( - " if (result is Result.Error err)" - ); - _ = sb.AppendLine(" return err;"); - _ = sb.AppendLine( - " totalAffected += ((Result.Ok)result).Value;" - ); - _ = sb.AppendLine(" batch.Clear();"); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(); - _ = sb.AppendLine(" if (batch.Count > 0)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" var finalResult = await ExecuteBulkUpsert{pascalName}BatchAsync(conn, batch).ConfigureAwait(false);" - ); - _ = sb.AppendLine( - " if (finalResult is Result.Error finalErr)" - ); - _ = sb.AppendLine(" return finalErr;"); - _ = sb.AppendLine( - " totalAffected += ((Result.Ok)finalResult).Value;" - ); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(); - _ = sb.AppendLine( - " return new Result.Ok(totalAffected);" - ); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" catch (Exception ex)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine( - " return new Result.Error(SqlError.FromException(ex));" - ); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(); - - // Generate batch execution helper - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" private static async Task> ExecuteBulkUpsert{pascalName}BatchAsync(" - ); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" NpgsqlConnection conn,"); - _ = sb.AppendLine(CultureInfo.InvariantCulture, $" List<({tupleType})> batch)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" if (batch.Count == 0)"); - _ = sb.AppendLine(" return new Result.Ok(0);"); - _ = sb.AppendLine(); + EmitBulkMethodPreamble(sb, table, pascalName, tupleType, upsert: true); // BUG3 fix: quote every identifier in emitted bulk-upsert SQL. Same // reasoning as bulk insert above. Applies to the ON CONFLICT pk list @@ -1592,25 +1420,7 @@ string pascalName updateCols.Select(c => $"\\\"{c.Name}\\\" = EXCLUDED.\\\"{c.Name}\\\"") ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" var sql = new System.Text.StringBuilder(\"INSERT INTO \\\"{table.Schema}\\\".\\\"{table.Name}\\\" ({colNames}) VALUES \");" - ); - _ = sb.AppendLine(); - _ = sb.AppendLine(" for (int i = 0; i < batch.Count; i++)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" if (i > 0) sql.Append(\", \");"); - - // Build VALUES placeholders - var placeholders = string.Join( - ", ", - insertable.Select((c, idx) => $"@p\" + (i * {insertable.Count} + {idx}) + \"") - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" sql.Append(\"({placeholders})\");" - ); - _ = sb.AppendLine(" }"); + EmitBulkValuesBuilder(sb, table, insertable, colNames); // Add ON CONFLICT DO UPDATE clause if (updateCols.Count > 0) @@ -1629,43 +1439,7 @@ string pascalName ); } - _ = sb.AppendLine(); - _ = sb.AppendLine(" await using var cmd = new NpgsqlCommand(sql.ToString(), conn);"); - _ = sb.AppendLine(); - _ = sb.AppendLine(" for (int i = 0; i < batch.Count; i++)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" var rec = batch[i];"); - - for (int i = 0; i < insertable.Count; i++) - { - var col = insertable[i]; - // Preserve the column name verbatim so generated record fields - // match the SQLite CLI output (which kept snake_case literally), - // and so consumers that reference `rec.user_id` etc. keep working. - var propName = col.Name; - if (col.IsNullable) - { - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"p\" + (i * {insertable.Count} + {i}), rec.{propName} ?? (object)DBNull.Value);" - ); - } - else - { - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"p\" + (i * {insertable.Count} + {i}), rec.{propName});" - ); - } - } - - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(); - _ = sb.AppendLine( - " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" - ); - _ = sb.AppendLine(" return new Result.Ok(rows);"); - _ = sb.AppendLine(" }"); + EmitBulkCommandExecution(sb, insertable); } /// @@ -2302,25 +2076,6 @@ private static string ToPascalCase(string name) return sb.ToString(); } - private static string ToCamelCase(string name) - { - if (string.IsNullOrEmpty(name)) - return name; - - // Preserve existing camel/Pascal input, just lowercase the first - // letter. For snake_case input, fall through to PascalCase then - // lowercase the leading letter. - if (!name.Contains('_', StringComparison.Ordinal)) - { - return char.ToLowerInvariant(name[0]) + name[1..]; - } - - var pascal = ToPascalCase(name); - if (string.IsNullOrEmpty(pascal)) - return pascal; - return char.ToLowerInvariant(pascal[0]) + pascal[1..]; - } - private static string EscapeForPreprocessor(string message) { if (string.IsNullOrEmpty(message)) diff --git a/DataProvider/DataProvider/SqliteProgram.cs b/DataProvider/DataProvider/SqliteProgram.cs index 656fc2d5..e6b8147f 100644 --- a/DataProvider/DataProvider/SqliteProgram.cs +++ b/DataProvider/DataProvider/SqliteProgram.cs @@ -265,10 +265,14 @@ as Result, SqlError>.Error< // Also emit an MSBuild-formatted error so IDE Problem Matchers pick it up Console.Error.WriteLine($"{sqlPath}(1,1): error DP0001: {prettyMeta}"); // Emit a compile-time error file so MSBuild surfaces the exact problem - var errorFile = Path.Combine(outDir.FullName, baseName + ".g.cs"); - var content = - $"// Auto-generated due to SQL error in {sqlPath}\n#error {EscapeForPreprocessor(prettyMeta)}\n"; - await File.WriteAllTextAsync(errorFile, content).ConfigureAwait(false); + await WriteCompileTimeErrorFileAsync( + outDir, + baseName, + sqlPath, + reason: "SQL error", + message: prettyMeta + ) + .ConfigureAwait(false); hadErrors = true; continue; } @@ -304,10 +308,14 @@ as Result, SqlError>.Error< // Also emit an MSBuild-formatted error so IDE Problem Matchers pick it up Console.Error.WriteLine($"{sqlPath}(1,1): error DL0002: {prettyGen}"); // Emit a compile-time error file so MSBuild surfaces the exact problem - var errorFile = Path.Combine(outDir.FullName, baseName + ".g.cs"); - var content = - $"// Auto-generated due to SQL generation error in {sqlPath}\n#error {EscapeForPreprocessor(prettyGen)}\n"; - await File.WriteAllTextAsync(errorFile, content).ConfigureAwait(false); + await WriteCompileTimeErrorFileAsync( + outDir, + baseName, + sqlPath, + reason: "SQL generation error", + message: prettyGen + ) + .ConfigureAwait(false); hadErrors = true; } } @@ -320,11 +328,15 @@ as Result, SqlError>.Error< { baseName = baseName[..^".generated".Length]; } - var errorFile = Path.Combine(outDir.FullName, baseName + ".g.cs"); - var content = - $"// Auto-generated due to unexpected error in {sqlPath}\n#error {EscapeForPreprocessor(ex.Message)}\n"; Console.Error.WriteLine($"{sqlPath}(1,1): error DL0003: {ex.Message}"); - await File.WriteAllTextAsync(errorFile, content).ConfigureAwait(false); + await WriteCompileTimeErrorFileAsync( + outDir, + baseName, + sqlPath, + reason: "unexpected error", + message: ex.Message + ) + .ConfigureAwait(false); hadErrors = true; } } @@ -361,7 +373,10 @@ as Result, SqlError>.Error< var notNull = reader.GetInt32(3) == 1; // notnull column var isPrimaryKey = reader.GetInt32(5) > 0; // pk column - var csharpType = MapSqliteTypeToCSharpType(sqliteType, !notNull); + var csharpType = SqliteTypeMapper.MapSqliteTypeToCSharpType( + sqliteType, + !notNull + ); columns.Add( new DatabaseColumn @@ -464,6 +479,20 @@ is Result.Error operationsFailure } } + private static async Task WriteCompileTimeErrorFileAsync( + DirectoryInfo outDir, + string baseName, + string sqlPath, + string reason, + string message + ) + { + var errorFile = Path.Combine(outDir.FullName, baseName + ".g.cs"); + var content = + $"// Auto-generated due to {reason} in {sqlPath}\n#error {EscapeForPreprocessor(message)}\n"; + await File.WriteAllTextAsync(errorFile, content).ConfigureAwait(false); + } + /// /// Generates a source file with the correct using statement for the specified connection type. /// @@ -615,35 +644,4 @@ private static string MakeConnectionStringAbsolute(string connectionString, stri var suffix = semicolonIdx >= 0 ? connectionString[semicolonIdx..] : string.Empty; return $"{prefix}{dataSourcePrefix}{absolutePath}{suffix}"; } - - /// - /// Maps SQLite types to C# types - /// - private static string MapSqliteTypeToCSharpType(string sqliteType, bool isNullable) - { - var baseType = sqliteType.ToUpperInvariant() switch - { - var t when t.Contains("INT", StringComparison.OrdinalIgnoreCase) => "long", - var t - when t.Contains("REAL", StringComparison.OrdinalIgnoreCase) - || t.Contains("FLOAT", StringComparison.OrdinalIgnoreCase) - || t.Contains("DOUBLE", StringComparison.OrdinalIgnoreCase) => "double", - var t - when t.Contains("DECIMAL", StringComparison.OrdinalIgnoreCase) - || t.Contains("NUMERIC", StringComparison.OrdinalIgnoreCase) => "double", - var t when t.Contains("BOOL", StringComparison.OrdinalIgnoreCase) => "bool", - var t - when t.Contains("DATE", StringComparison.OrdinalIgnoreCase) - || t.Contains("TIME", StringComparison.OrdinalIgnoreCase) => "string", // SQLite stores dates as text - var t when t.Contains("BLOB", StringComparison.OrdinalIgnoreCase) => "byte[]", - _ => "string", - }; - - if (isNullable && baseType != "string" && baseType != "byte[]") - { - return baseType + "?"; - } - - return baseType; - } } diff --git a/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/AdoNetDatabaseEffects.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/AdoNetDatabaseEffects.cs index cb004a6f..72cbb4cc 100644 --- a/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/AdoNetDatabaseEffects.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/AdoNetDatabaseEffects.cs @@ -85,7 +85,7 @@ IEnumerable parameters } catch (DbException ex) { - return Error(new SqlError("Failed to get column metadata", ex)); + return Error(new SqlError($"Failed to get column metadata: {ex.Message}", ex)); } catch (InvalidOperationException ex) { diff --git a/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGenerator.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGenerator.cs index 267dcf38..40852bd0 100644 --- a/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/DataAccessGenerator.cs @@ -122,6 +122,26 @@ public static string GenerateParameterList(IReadOnlyList paramete return string.Join(", ", parameters.Select(p => $"object {p.Name}")); } + /// + /// Appends portable AddParameter binding lines for the given columns. + /// Nullable columns coalesce to DBNull.Value; non-nullable bind directly. + /// + private static void AppendParameterBindings( + StringBuilder sb, + IReadOnlyList columns + ) + { + foreach (var column in columns) + { + var escaped = EscapeReservedKeyword(column.Name); + var value = column.IsNullable ? $"{escaped} ?? (object)DBNull.Value" : escaped; + sb.AppendLine( + CultureInfo.InvariantCulture, + $" AddParameter(command, \"@{column.Name}\", {value});" + ); + } + } + /// /// Generates a data access extension method for querying /// @@ -352,72 +372,24 @@ public static Result GenerateInsertMethod( ); sb.AppendLine(" {"); - // Generate INSERT SQL (no last_insert_rowid - all PKs are UUIDs) - // All identifiers are lowercase - no quoting needed for cross-platform compatibility - var columnNames = string.Join(", ", insertableColumns.Select(c => c.Name)); + // Generate INSERT SQL (no last_insert_rowid - all PKs are UUIDs). Identifiers are + // double-quoted so the statement matches case-preserved names on every platform. + var columnNames = string.Join( + ", ", + insertableColumns.Select(c => QuotedIdentifier(c.Name)) + ); var parameterNames = string.Join(", ", insertableColumns.Select(c => $"@{c.Name}")); sb.AppendLine( CultureInfo.InvariantCulture, - $" const string sql = \"INSERT INTO {table.Name} ({columnNames}) VALUES ({parameterNames})\";" - ); - sb.AppendLine(); - sb.AppendLine(" if (transaction.Connection is null)"); - sb.AppendLine( - " return new Result.Error(new SqlError(\"Transaction has no connection\"));" + $" const string sql = \"INSERT INTO {QuotedIdentifier(table.Name)} ({columnNames}) VALUES ({parameterNames})\";" ); - sb.AppendLine(); - sb.AppendLine(" try"); - sb.AppendLine(" {"); - - var commandType = connectionType.Replace("Connection", "Command", StringComparison.Ordinal); - var transactionType = connectionType.Replace( - "Connection", - "Transaction", - StringComparison.Ordinal - ); - sb.AppendLine( - CultureInfo.InvariantCulture, - $" using (var command = new {commandType}(sql, ({connectionType})transaction.Connection!, ({transactionType})transaction))" - ); - sb.AppendLine(" {"); + AppendTransactionConnectionGuard(sb); + AppendTransactionCommandOpen(sb); // Add parameters - foreach (var column in insertableColumns) - { - var paramName = EscapeReservedKeyword(column.Name); - if (column.IsNullable) - { - sb.AppendLine( - CultureInfo.InvariantCulture, - $" command.Parameters.AddWithValue(\"@{column.Name}\", {paramName} ?? (object)DBNull.Value);" - ); - } - else - { - sb.AppendLine( - CultureInfo.InvariantCulture, - $" command.Parameters.AddWithValue(\"@{column.Name}\", {paramName});" - ); - } - } - - sb.AppendLine(); - sb.AppendLine( - " var rowsAffected = await command.ExecuteNonQueryAsync().ConfigureAwait(false);" - ); - sb.AppendLine( - " return new Result.Ok(rowsAffected);" - ); - sb.AppendLine(" }"); - sb.AppendLine(" }"); - sb.AppendLine(" catch (Exception ex)"); - sb.AppendLine(" {"); - sb.AppendLine( - " return new Result.Error(new SqlError(\"Insert failed\", ex));" - ); - sb.AppendLine(" }"); - sb.AppendLine(" }"); + AppendParameterBindings(sb, insertableColumns); + AppendNonQueryTail(sb, failure: "Insert failed", connectionType: connectionType); return new Result.Ok(sb.ToString()); } @@ -587,74 +559,26 @@ public static Result GenerateUpdateMethod( ); sb.AppendLine(" {"); - // Generate UPDATE SQL - all identifiers lowercase, no quoting needed - var setClause = string.Join(", ", updateableColumns.Select(c => $"{c.Name} = @{c.Name}")); + // Generate UPDATE SQL with double-quoted identifiers (see the INSERT generator) + var setClause = string.Join( + ", ", + updateableColumns.Select(c => $"{QuotedIdentifier(c.Name)} = @{c.Name}") + ); var whereClause = string.Join( " AND ", - primaryKeyColumns.Select(c => $"{c.Name} = @{c.Name}") + primaryKeyColumns.Select(c => $"{QuotedIdentifier(c.Name)} = @{c.Name}") ); sb.AppendLine( CultureInfo.InvariantCulture, - $" const string sql = \"UPDATE {table.Name} SET {setClause} WHERE {whereClause}\";" - ); - sb.AppendLine(); - sb.AppendLine(" if (transaction.Connection is null)"); - sb.AppendLine( - " return new Result.Error(new SqlError(\"Transaction has no connection\"));" + $" const string sql = \"UPDATE {QuotedIdentifier(table.Name)} SET {setClause} WHERE {whereClause}\";" ); - sb.AppendLine(); - sb.AppendLine(" try"); - sb.AppendLine(" {"); - - var commandType = connectionType.Replace("Connection", "Command", StringComparison.Ordinal); - var transactionType = connectionType.Replace( - "Connection", - "Transaction", - StringComparison.Ordinal - ); - sb.AppendLine( - CultureInfo.InvariantCulture, - $" using (var command = new {commandType}(sql, ({connectionType})transaction.Connection!, ({transactionType})transaction))" - ); - sb.AppendLine(" {"); + AppendTransactionConnectionGuard(sb); + AppendTransactionCommandOpen(sb); // Add parameters (nullable types use null-coalescing to DBNull.Value) - foreach (var column in allColumns) - { - var escaped = EscapeReservedKeyword(column.Name); - if (column.IsNullable) - { - sb.AppendLine( - CultureInfo.InvariantCulture, - $" command.Parameters.AddWithValue(\"@{column.Name}\", {escaped} ?? (object)DBNull.Value);" - ); - } - else - { - sb.AppendLine( - CultureInfo.InvariantCulture, - $" command.Parameters.AddWithValue(\"@{column.Name}\", {escaped});" - ); - } - } - - sb.AppendLine(); - sb.AppendLine( - " var rowsAffected = await command.ExecuteNonQueryAsync().ConfigureAwait(false);" - ); - sb.AppendLine( - " return new Result.Ok(rowsAffected);" - ); - sb.AppendLine(" }"); - sb.AppendLine(" }"); - sb.AppendLine(" catch (Exception ex)"); - sb.AppendLine(" {"); - sb.AppendLine( - " return new Result.Error(new SqlError(\"Update failed\", ex));" - ); - sb.AppendLine(" }"); - sb.AppendLine(" }"); + AppendParameterBindings(sb, allColumns); + AppendNonQueryTail(sb, failure: "Update failed", connectionType: connectionType); return new Result.Ok(sb.ToString()); } @@ -773,12 +697,7 @@ public static Result GenerateBulkInsertMethod( sb.AppendLine(" {"); sb.AppendLine(" if (batch.Count == 0)"); sb.AppendLine(" return new Result.Ok(0);"); - sb.AppendLine(); - sb.AppendLine(" if (transaction.Connection is null)"); - sb.AppendLine( - " return new Result.Error(new SqlError(\"Transaction has no connection\"));" - ); - sb.AppendLine(); + AppendTransactionConnectionGuard(sb); // Build the SQL with placeholders - all identifiers lowercase, no quoting var columnNames = string.Join(", ", insertableColumns.Select(c => c.Name)); @@ -815,33 +734,7 @@ public static Result GenerateBulkInsertMethod( ); } sb.AppendLine(" }"); - sb.AppendLine(); - - var commandType = connectionType.Replace("Connection", "Command", StringComparison.Ordinal); - var transactionType = connectionType.Replace( - "Connection", - "Transaction", - StringComparison.Ordinal - ); - sb.AppendLine( - CultureInfo.InvariantCulture, - $" using (var command = new {commandType}(sql.ToString(), ({connectionType})transaction.Connection!, ({transactionType})transaction))" - ); - sb.AppendLine(" {"); - sb.AppendLine(" for (int i = 0; i < parameters.Count; i++)"); - sb.AppendLine(" {"); - sb.AppendLine( - " command.Parameters.AddWithValue(\"@p\" + i, parameters[i] ?? (object)DBNull.Value);" - ); - sb.AppendLine(" }"); - sb.AppendLine(); - sb.AppendLine( - " var rowsAffected = await command.ExecuteNonQueryAsync().ConfigureAwait(false);" - ); - sb.AppendLine( - " return new Result.Ok(rowsAffected);" - ); - sb.AppendLine(" }"); + AppendBatchCommandExecute(sb, connectionType: connectionType); sb.AppendLine(" }"); return new Result.Ok(sb.ToString()); @@ -968,12 +861,7 @@ public static Result GenerateBulkUpsertMethod( sb.AppendLine(" {"); sb.AppendLine(" if (batch.Count == 0)"); sb.AppendLine(" return new Result.Ok(0);"); - sb.AppendLine(); - sb.AppendLine(" if (transaction.Connection is null)"); - sb.AppendLine( - " return new Result.Error(new SqlError(\"Transaction has no connection\"));" - ); - sb.AppendLine(); + AppendTransactionConnectionGuard(sb); // Build the SQL with placeholders - database-specific upsert syntax // All identifiers lowercase, no quoting needed for cross-platform compatibility @@ -1042,15 +930,135 @@ public static Result GenerateBulkUpsertMethod( $" sql.Append(\" ON CONFLICT ({pkColumnNames}) DO UPDATE SET {updateSet}\");" ); } + AppendBatchCommandExecute(sb, connectionType: connectionType); + sb.AppendLine(" }"); + + return new Result.Ok(sb.ToString()); + } + /// + /// Emits the generated guard that rejects transactions without a live connection. + /// + /// Builder receiving the generated code. + private static void AppendTransactionConnectionGuard(StringBuilder sb) + { sb.AppendLine(); + sb.AppendLine(" if (transaction.Connection is null)"); + sb.AppendLine( + " return new Result.Error(new SqlError(\"Transaction has no connection\"));" + ); + sb.AppendLine(); + } - var commandType = connectionType.Replace("Connection", "Command", StringComparison.Ordinal); - var transactionType = connectionType.Replace( - "Connection", - "Transaction", - StringComparison.Ordinal + /// + /// Emits the generated try-block opening and a transaction-scoped command created by + /// the transaction's own connection, so the code runs on any ADO.NET provider. + /// + /// Builder receiving the generated code. + private static void AppendTransactionCommandOpen(StringBuilder sb) + { + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" using (var command = transaction.Connection.CreateCommand())"); + sb.AppendLine(" {"); + sb.AppendLine(" command.Transaction = transaction;"); + sb.AppendLine(" command.CommandText = sql;"); + } + + /// + /// Emits the non-query execution, the Result mapping, and the method's closing, including + /// the portable AddParameter local function the bindings call. + /// + /// Builder receiving the generated code. + /// Error message wrapped around a thrown exception. + /// Connection type the schema was read with (documented only). + private static void AppendNonQueryTail(StringBuilder sb, string failure, string connectionType) + { + sb.AppendLine(); + AppendNonQueryExecute(sb, indent: " "); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (Exception ex)"); + sb.AppendLine(" {"); + sb.AppendLine( + CultureInfo.InvariantCulture, + $" return new Result.Error(new SqlError(\"{failure}\", ex));" + ); + sb.AppendLine(" }"); + AppendAddParameterFunction(sb, connectionType: connectionType); + sb.AppendLine(" }"); + } + + /// + /// Emits the rows-affected execution, async when the provider's command supports it. + /// + private static void AppendNonQueryExecute(StringBuilder sb, string indent) + { + sb.AppendLine( + CultureInfo.InvariantCulture, + $"{indent}var rowsAffected = command is System.Data.Common.DbCommand dbCommand" + ); + sb.AppendLine( + CultureInfo.InvariantCulture, + $"{indent} ? await dbCommand.ExecuteNonQueryAsync().ConfigureAwait(false)" + ); + sb.AppendLine(CultureInfo.InvariantCulture, $"{indent} : command.ExecuteNonQuery();"); + sb.AppendLine( + CultureInfo.InvariantCulture, + $"{indent}return new Result.Ok(rowsAffected);" + ); + } + + /// + /// Emits the provider-neutral parameter binder used by generated table operations. + /// + private static void AppendAddParameterFunction(StringBuilder sb, string connectionType) + { + sb.AppendLine(); + sb.AppendLine( + CultureInfo.InvariantCulture, + $" // Portable ADO.NET binding; the schema was read through {connectionType}." ); + sb.AppendLine( + " static void AddParameter(IDbCommand command, string name, object value)" + ); + sb.AppendLine(" {"); + sb.AppendLine(" var parameter = command.CreateParameter();"); + sb.AppendLine(" parameter.ParameterName = name;"); + sb.AppendLine(" parameter.Value = value;"); + sb.AppendLine(" command.Parameters.Add(parameter);"); + sb.AppendLine(" }"); + } + + /// + /// Double-quotes an identifier (SQL standard) for a generated C# string literal. + /// + private static string QuotedIdentifier(string name) => + $"\\\"{name.Replace("\"", "\\\"\\\"", StringComparison.Ordinal)}\\\""; + + /// + /// Derives the platform command and transaction type names from a connection type. + /// + /// Database connection type (e.g., SqliteConnection). + /// The matching command and transaction type names. + private static (string CommandType, string TransactionType) DeriveTransactionTypes( + string connectionType + ) => + ( + connectionType.Replace("Connection", "Command", StringComparison.Ordinal), + connectionType.Replace("Connection", "Transaction", StringComparison.Ordinal) + ); + + /// + /// Emits the generated batch execution tail: transaction-scoped command creation, + /// positional parameter binding, and non-query execution. + /// + /// Builder receiving the generated code. + /// Database connection type (e.g., SqliteConnection). + private static void AppendBatchCommandExecute(StringBuilder sb, string connectionType) + { + sb.AppendLine(); + var (commandType, transactionType) = DeriveTransactionTypes(connectionType); sb.AppendLine( CultureInfo.InvariantCulture, $" using (var command = new {commandType}(sql.ToString(), ({connectionType})transaction.Connection!, ({transactionType})transaction))" @@ -1070,9 +1078,6 @@ public static Result GenerateBulkUpsertMethod( " return new Result.Ok(rowsAffected);" ); sb.AppendLine(" }"); - sb.AppendLine(" }"); - - return new Result.Ok(sb.ToString()); } /// diff --git a/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ModelGenerator.cs b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ModelGenerator.cs index c6567e28..aa687e26 100644 --- a/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ModelGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/CodeGeneration/ModelGenerator.cs @@ -21,15 +21,8 @@ public static Result GenerateRecordType( IReadOnlyList columns ) { - if (string.IsNullOrWhiteSpace(typeName)) - return new Result.Error( - new SqlError("typeName cannot be null or empty") - ); - - if (columns == null || columns.Count == 0) - return new Result.Error( - new SqlError("columns cannot be null or empty") - ); + if (ValidateTypeNameAndColumns(typeName, columns) is { } error) + return new Result.Error(error); var sb = new StringBuilder(); @@ -277,15 +270,8 @@ public static Result GenerateRawRecordType( IReadOnlyList columns ) { - if (string.IsNullOrWhiteSpace(typeName)) - return new Result.Error( - new SqlError("typeName cannot be null or empty") - ); - - if (columns == null || columns.Count == 0) - return new Result.Error( - new SqlError("columns cannot be null or empty") - ); + if (ValidateTypeNameAndColumns(typeName, columns) is { } error) + return new Result.Error(error); var sb = new StringBuilder(); sb.AppendLine(CultureInfo.InvariantCulture, $"internal record {typeName}("); @@ -303,4 +289,12 @@ IReadOnlyList columns return new Result.Ok(sb.ToString()); } + + private static SqlError? ValidateTypeNameAndColumns( + string typeName, + IReadOnlyList? columns + ) => + string.IsNullOrWhiteSpace(typeName) ? new SqlError("typeName cannot be null or empty") + : columns == null || columns.Count == 0 ? new SqlError("columns cannot be null or empty") + : null; } diff --git a/DataProvider/Nimblesite.DataProvider.Core/DbConnectionExtensions.cs b/DataProvider/Nimblesite.DataProvider.Core/DbConnectionExtensions.cs index 8b2799e3..ed597e30 100644 --- a/DataProvider/Nimblesite.DataProvider.Core/DbConnectionExtensions.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/DbConnectionExtensions.cs @@ -70,16 +70,11 @@ public static Result, SqlError> Query( try { - using var command = connection.CreateCommand(); - command.CommandText = sql; - - if (parameters != null) - { - foreach (var parameter in parameters) - { - command.Parameters.Add(parameter); - } - } + using var command = CreateCommand( + connection: connection, + sql: sql, + parameters: parameters + ); var results = new List(); using var reader = command.ExecuteReader(); @@ -143,16 +138,11 @@ public static Result Execute( try { - using var command = connection.CreateCommand(); - command.CommandText = sql; - - if (parameters != null) - { - foreach (var parameter in parameters) - { - command.Parameters.Add(parameter); - } - } + using var command = CreateCommand( + connection: connection, + sql: sql, + parameters: parameters + ); var rowsAffected = command.ExecuteNonQuery(); return new Result.Ok(rowsAffected); @@ -189,16 +179,11 @@ public static Result Execute( try { - using var command = connection.CreateCommand(); - command.CommandText = sql; - - if (parameters != null) - { - foreach (var parameter in parameters) - { - command.Parameters.Add(parameter); - } - } + using var command = CreateCommand( + connection: connection, + sql: sql, + parameters: parameters + ); var result = command.ExecuteScalar(); return new Result.Ok(result is T value ? value : default); @@ -255,4 +240,27 @@ public static Result, SqlError> GetRecords( var sql = ((Result.Ok)sqlResult).Value; return connection.Query(sql, parameters, mapper); } + + /// + /// Create a command with SQL text and optional parameters + /// + private static IDbCommand CreateCommand( + IDbConnection connection, + string sql, + IEnumerable? parameters + ) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + + if (parameters != null) + { + foreach (var parameter in parameters) + { + command.Parameters.Add(parameter); + } + } + + return command; + } } diff --git a/DataProvider/Nimblesite.DataProvider.Core/DbTransact.cs b/DataProvider/Nimblesite.DataProvider.Core/DbTransact.cs index 4d052936..d4d8a34c 100644 --- a/DataProvider/Nimblesite.DataProvider.Core/DbTransact.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/DbTransact.cs @@ -34,28 +34,16 @@ public static class DbTransact /// The database connection. It will be opened if not already open. /// The asynchronous delegate to execute within the transaction. /// A task that completes when the transactional operation finishes. - public static async Task Transact(this DbConnection cn, Func body) + public static Task Transact(this DbConnection cn, Func body) { ArgumentNullException.ThrowIfNull(cn); ArgumentNullException.ThrowIfNull(body); - if (cn.State != ConnectionState.Open) - await cn.OpenAsync().ConfigureAwait(false); - -#pragma warning disable CA2007 - await using var tx = await cn.BeginTransactionAsync().ConfigureAwait(false); -#pragma warning restore CA2007 - - try + return cn.Transact(async tx => { await body(tx).ConfigureAwait(false); - await tx.CommitAsync().ConfigureAwait(false); - } - catch - { - await tx.RollbackAsync().ConfigureAwait(false); - throw; - } + return true; + }); } /// diff --git a/DataProvider/Nimblesite.DataProvider.Core/DbTransactionExtensions.cs b/DataProvider/Nimblesite.DataProvider.Core/DbTransactionExtensions.cs index e6a5e58e..3ffc6215 100644 --- a/DataProvider/Nimblesite.DataProvider.Core/DbTransactionExtensions.cs +++ b/DataProvider/Nimblesite.DataProvider.Core/DbTransactionExtensions.cs @@ -23,54 +23,27 @@ public static Result, SqlError> Query( string sql, IEnumerable? parameters = null, Func? mapper = null - ) - { - if (transaction?.Connection == null) - return new Result, SqlError>.Error, SqlError>( - SqlError.Create("Transaction or connection is null") - ); - - if (string.IsNullOrWhiteSpace(sql)) - return new Result, SqlError>.Error, SqlError>( - SqlError.Create("SQL is null or empty") - ); - - try - { - using var command = transaction.Connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = sql; - - if (parameters != null) + ) => + RunCommand>( + transaction: transaction, + sql: sql, + parameters: parameters, + operation: command => { - foreach (var parameter in parameters) - { - command.Parameters.Add(parameter); - } - } + var results = new List(); + using var reader = command.ExecuteReader(); - var results = new List(); - using var reader = command.ExecuteReader(); - - while (reader.Read()) - { - if (mapper != null) + while (reader.Read()) { - results.Add(mapper(reader)); + if (mapper != null) + { + results.Add(mapper(reader)); + } } - } - return new Result, SqlError>.Ok, SqlError>( - results.AsReadOnly() - ); - } - catch (Exception ex) - { - return new Result, SqlError>.Error, SqlError>( - SqlError.FromException(ex) - ); - } - } + return results.AsReadOnly(); + } + ); /// /// Execute a non-query command within a transaction @@ -83,40 +56,13 @@ public static Result Execute( this IDbTransaction transaction, string sql, IEnumerable? parameters = null - ) - { - if (transaction?.Connection == null) - return new Result.Error( - SqlError.Create("Transaction or connection is null") - ); - - if (string.IsNullOrWhiteSpace(sql)) - return new Result.Error( - SqlError.Create("SQL is null or empty") - ); - - try - { - using var command = transaction.Connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = sql; - - if (parameters != null) - { - foreach (var parameter in parameters) - { - command.Parameters.Add(parameter); - } - } - - var rowsAffected = command.ExecuteNonQuery(); - return new Result.Ok(rowsAffected); - } - catch (Exception ex) - { - return new Result.Error(SqlError.FromException(ex)); - } - } + ) => + RunCommand( + transaction: transaction, + sql: sql, + parameters: parameters, + operation: command => command.ExecuteNonQuery() + ); /// /// Execute a scalar command within a transaction @@ -130,38 +76,87 @@ public static Result Execute( this IDbTransaction transaction, string sql, IEnumerable? parameters = null - ) + ) => + RunCommand( + transaction: transaction, + sql: sql, + parameters: parameters, + operation: command => command.ExecuteScalar() is T value ? value : default + ); + + /// + /// Validate the transaction, its bound connection, and the SQL text + /// + private static SqlError? Validate(IDbTransaction transaction, string sql) { if (transaction?.Connection == null) - return new Result.Error( - SqlError.Create("Transaction or connection is null") - ); + return SqlError.Create("Transaction or connection is null"); if (string.IsNullOrWhiteSpace(sql)) - return new Result.Error( - SqlError.Create("SQL is null or empty") - ); + return SqlError.Create("SQL is null or empty"); + + return null; + } + + /// + /// Create a transaction-bound command, run the operation, and convert failures to SqlError + /// + private static Result RunCommand( + IDbTransaction transaction, + string sql, + IEnumerable? parameters, + Func operation + ) + { + if (Validate(transaction: transaction, sql: sql) is { } guardError) + return new Result.Error(guardError); try { - using var command = transaction.Connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = sql; - - if (parameters != null) - { - foreach (var parameter in parameters) - { - command.Parameters.Add(parameter); - } - } + if (transaction.Connection is not { } connection) + return new Result.Error( + SqlError.Create("Transaction or connection is null") + ); + + using var command = CreateCommand( + connection: connection, + transaction: transaction, + sql: sql, + parameters: parameters + ); - var result = command.ExecuteScalar(); - return new Result.Ok(result is T value ? value : default); + return new Result.Ok(operation(command)); } catch (Exception ex) { - return new Result.Error(SqlError.FromException(ex)); + return new Result.Error( + SqlError.FromException(ex) + ); + } + } + + /// + /// Create a command bound to the transaction, with SQL text and optional parameters + /// + private static IDbCommand CreateCommand( + IDbConnection connection, + IDbTransaction transaction, + string sql, + IEnumerable? parameters + ) + { + var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + + if (parameters != null) + { + foreach (var parameter in parameters) + { + command.Parameters.Add(parameter); + } } + + return command; } } diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/CoreCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/CoreCoverageTests.cs index b7390987..5d8bf39d 100644 --- a/DataProvider/Nimblesite.DataProvider.Example.Tests/CoreCoverageTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/CoreCoverageTests.cs @@ -892,14 +892,20 @@ INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); } - public void Dispose() + public void Dispose() => DisposeSqliteFixture(connection: _connection, dbPath: _dbPath); + + /// + /// Shared best-effort cleanup for SQLite file fixtures: disposes the connection and + /// deletes the database file, tolerating a file locked by another process. + /// + internal static void DisposeSqliteFixture(SqliteConnection? connection, string dbPath) { - _connection?.Dispose(); - if (File.Exists(_dbPath)) + connection?.Dispose(); + if (File.Exists(dbPath)) { try { - File.Delete(_dbPath); + File.Delete(dbPath); } #pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort catch (IOException) diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/DataProviderIntegrationTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/DataProviderIntegrationTests.cs index 825ba8d9..e2c321d3 100644 --- a/DataProvider/Nimblesite.DataProvider.Example.Tests/DataProviderIntegrationTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/DataProviderIntegrationTests.cs @@ -1230,21 +1230,6 @@ public void FluentQueryBuilder_AllComparisonOperators_GenerateCorrectSQL() #endregion - public void Dispose() - { - _connection?.Dispose(); - if (File.Exists(_dbPath)) - { - try - { - File.Delete(_dbPath); - } -#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort - catch (IOException) - { - /* File may be locked */ - } -#pragma warning restore CA1031 - } - } + public void Dispose() => + CoreCoverageTests.DisposeSqliteFixture(connection: _connection, dbPath: _dbPath); } diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.Updates.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.Updates.cs new file mode 100644 index 00000000..6b89cffd --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.Updates.cs @@ -0,0 +1,313 @@ +using Nimblesite.Sql.Model; +using Outcome; +using Xunit; +using AffectedRowsOk = Outcome.Result.Ok< + int, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.DataProvider.Example.Tests; + +/// Update operation coverage for the generated customer, invoice, address, and order methods. +public sealed partial class GeneratedOperationsCoverageTests +{ + /// Checks affected rows for UpdateCustomerAsync_WithValidData_ReturnsOk. + [Fact] + public async Task UpdateCustomerAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + // First get an existing customer ID + var queryResult = tx.Query( + sql: "SELECT Id FROM Customer LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var customers = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + var customerId = customers.Value[0]; + + var result = await tx.UpdateCustomerAsync( + customerId, + "Updated Customer", + "updated@test.com", + "555-9999", + "2024-06-01" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(result).Value); + }) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateInvoiceAsync_WithValidData_ReturnsOk. + [Fact] + public async Task UpdateInvoiceAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id FROM Invoice LIMIT 1", + mapper: reader => reader.GetString(0) + ); + var invoices = (Result, SqlError>.Ok< + IReadOnlyList, + SqlError + >)queryResult; + var invoiceId = invoices.Value[0]; + + var result = await tx.UpdateInvoiceAsync( + invoiceId, + "INV-UPDATED", + "2024-07-01", + "Updated Corp", + "updated@billing.com", + 2000.00, + 100.00, + "Updated notes" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(result).Value); + }) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateInvoiceLineAsync_WithValidData_ReturnsOk. + [Fact] + public async Task UpdateInvoiceLineAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, InvoiceId FROM InvoiceLine LIMIT 1", + mapper: reader => (Id: reader.GetString(0), InvoiceId: reader.GetString(1)) + ); + var lines = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string InvoiceId)>, + SqlError + >)queryResult; + var line = lines.Value[0]; + + var result = await tx.UpdateInvoiceLineAsync( + line.Id, + line.InvoiceId, + "Updated Description", + 3.0, + 100.00, + 300.00, + 10.0, + "Updated notes" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(result).Value); + }) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateAddressAsync_WithValidData_ReturnsOk. + [Fact] + public async Task UpdateAddressAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, CustomerId FROM Address LIMIT 1", + mapper: reader => (Id: reader.GetString(0), CustomerId: reader.GetString(1)) + ); + var addresses = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string CustomerId)>, + SqlError + >)queryResult; + var addr = addresses.Value[0]; + + var result = await tx.UpdateAddressAsync( + addr.Id, + addr.CustomerId, + "200 Updated Ave", + "UpdatedCity", + "UC", + "67890", + "USA" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(result).Value); + }) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateOrdersAsync_WithValidData_ReturnsOk. + [Fact] + public async Task UpdateOrdersAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, CustomerId FROM Orders LIMIT 1", + mapper: reader => (Id: reader.GetString(0), CustomerId: reader.GetString(1)) + ); + var orders = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string CustomerId)>, + SqlError + >)queryResult; + var order = orders.Value[0]; + + var result = await tx.UpdateOrdersAsync( + order.Id, + "ORD-UPDATED", + "2024-07-01", + order.CustomerId, + 1500.00, + "Completed" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(result).Value); + }) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateOrderItemAsync_WithValidData_ReturnsOk. + [Fact] + public async Task UpdateOrderItemAsync_WithValidData_ReturnsOk() + { + await SetupSchemaAndSeed().ConfigureAwait(false); + + await _connection + .Transact(async tx => + { + var queryResult = tx.Query( + sql: "SELECT Id, OrderId FROM OrderItem LIMIT 1", + mapper: reader => (Id: reader.GetString(0), OrderId: reader.GetString(1)) + ); + var items = (Result, SqlError>.Ok< + IReadOnlyList<(string Id, string OrderId)>, + SqlError + >)queryResult; + var item = items.Value[0]; + + var result = await tx.UpdateOrderItemAsync( + item.Id, + item.OrderId, + "Updated Widget", + 10.0, + 50.00, + 500.00 + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(result).Value); + }) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateCustomerAsync_WithNonExistentId_ReturnsZeroRows. + [Fact] + public async Task UpdateCustomerAsync_WithNonExistentId_ReturnsZeroRows() + { + await AssertAffectedRows( + execute: tx => + tx.UpdateCustomerAsync( + "nonexistent-id", + "Updated", + "u@t.com", + "555-0000", + "2024-01-01" + ), + expectedRows: 0, + seed: true + ) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateInvoiceAsync_WithNonExistentId_ReturnsZeroRows. + [Fact] + public async Task UpdateInvoiceAsync_WithNonExistentId_ReturnsZeroRows() + { + await AssertAffectedRows( + execute: tx => + tx.UpdateInvoiceAsync( + "nonexistent", + "INV-X", + "2024-01-01", + "X", + "x@t.com", + 0.0, + 0.0, + "n" + ), + expectedRows: 0, + seed: true + ) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateAddressAsync_WithNonExistentId_ReturnsZeroRows. + [Fact] + public async Task UpdateAddressAsync_WithNonExistentId_ReturnsZeroRows() + { + await AssertAffectedRows( + execute: tx => + tx.UpdateAddressAsync( + "nonexistent", + "cust-1", + "St", + "City", + "ST", + "00000", + "US" + ), + expectedRows: 0, + seed: true + ) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateInvoiceLineAsync_WithNonExistentId_ReturnsZeroRows. + [Fact] + public async Task UpdateInvoiceLineAsync_WithNonExistentId_ReturnsZeroRows() + { + await AssertAffectedRows( + execute: tx => + tx.UpdateInvoiceLineAsync( + "nonexistent", + "inv-1", + "Desc", + 1.0, + 10.0, + 10.0, + 0.0, + "n" + ), + expectedRows: 0, + seed: true + ) + .ConfigureAwait(false); + } + + /// Checks affected rows for UpdateOrderItemAsync_WithNonExistentId_ReturnsZeroRows. + [Fact] + public async Task UpdateOrderItemAsync_WithNonExistentId_ReturnsZeroRows() + { + await AssertAffectedRows( + execute: tx => + tx.UpdateOrderItemAsync("nonexistent", "ord-1", "Product", 1.0, 10.0, 10.0), + expectedRows: 0, + seed: true + ) + .ConfigureAwait(false); + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.cs index 6976628f..260a3eb9 100644 --- a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsCoverageTests.cs @@ -1,7 +1,13 @@ +using System.Data; using Microsoft.Data.Sqlite; using Nimblesite.Sql.Model; using Outcome; using Xunit; +using AffectedRowsOk = Outcome.Result.Ok< + int, + Nimblesite.Sql.Model.SqlError +>; +using AffectedRowsResult = Outcome.Result; namespace Nimblesite.DataProvider.Example.Tests; @@ -10,7 +16,7 @@ namespace Nimblesite.DataProvider.Example.Tests; /// /// Tests for generated Insert/Update operations and SampleDataSeeder /// -public sealed class GeneratedOperationsCoverageTests : IDisposable +public sealed partial class GeneratedOperationsCoverageTests : IDisposable { private readonly string _dbPath; private readonly SqliteConnection _connection; @@ -37,67 +43,26 @@ public async Task SampleDataSeeder_SeedDataAsync_InsertsAllEntities() [Fact] public async Task InsertCustomerAsync_WithValidData_ReturnsOk() { - await SetupSchema().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var result = await tx.InsertCustomerAsync( + await AssertAffectedRows( + execute: tx => + tx.InsertCustomerAsync( Guid.NewGuid().ToString(), "Test Customer", "test@test.com", "555-1234", "2024-01-01" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateCustomerAsync_WithValidData_ReturnsOk() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - // First get an existing customer ID - var queryResult = tx.Query( - sql: "SELECT Id FROM Customer LIMIT 1", - mapper: reader => reader.GetString(0) - ); - var customers = (Result, SqlError>.Ok< - IReadOnlyList, - SqlError - >)queryResult; - var customerId = customers.Value[0]; - - var result = await tx.UpdateCustomerAsync( - customerId, - "Updated Customer", - "updated@test.com", - "555-9999", - "2024-06-01" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - var ok = (Result.Ok)result; - Assert.Equal(1, ok.Value); - }) + ), + expectedRows: 1 + ) .ConfigureAwait(false); } [Fact] public async Task InsertInvoiceAsync_WithValidData_ReturnsOk() { - await SetupSchema().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var result = await tx.InsertInvoiceAsync( + await AssertAffectedRows( + execute: tx => + tx.InsertInvoiceAsync( Guid.NewGuid().ToString(), "INV-TEST-001", "2024-06-01", @@ -106,44 +71,9 @@ await _connection 1500.00, null, "Test invoice" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateInvoiceAsync_WithValidData_ReturnsOk() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var queryResult = tx.Query( - sql: "SELECT Id FROM Invoice LIMIT 1", - mapper: reader => reader.GetString(0) - ); - var invoices = (Result, SqlError>.Ok< - IReadOnlyList, - SqlError - >)queryResult; - var invoiceId = invoices.Value[0]; - - var result = await tx.UpdateInvoiceAsync( - invoiceId, - "INV-UPDATED", - "2024-07-01", - "Updated Corp", - "updated@billing.com", - 2000.00, - 100.00, - "Updated notes" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) + ), + expectedRows: 1 + ) .ConfigureAwait(false); } @@ -176,41 +106,7 @@ await _connection "Test notes" ) .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateInvoiceLineAsync_WithValidData_ReturnsOk() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var queryResult = tx.Query( - sql: "SELECT Id, InvoiceId FROM InvoiceLine LIMIT 1", - mapper: reader => (Id: reader.GetString(0), InvoiceId: reader.GetString(1)) - ); - var lines = (Result, SqlError>.Ok< - IReadOnlyList<(string Id, string InvoiceId)>, - SqlError - >)queryResult; - var line = lines.Value[0]; - - var result = await tx.UpdateInvoiceLineAsync( - line.Id, - line.InvoiceId, - "Updated Description", - 3.0, - 100.00, - 300.00, - 10.0, - "Updated notes" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); + Assert.Equal(1, Assert.IsType(result).Value); }) .ConfigureAwait(false); } @@ -242,40 +138,7 @@ await _connection "USA" ) .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateAddressAsync_WithValidData_ReturnsOk() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var queryResult = tx.Query( - sql: "SELECT Id, CustomerId FROM Address LIMIT 1", - mapper: reader => (Id: reader.GetString(0), CustomerId: reader.GetString(1)) - ); - var addresses = (Result, SqlError>.Ok< - IReadOnlyList<(string Id, string CustomerId)>, - SqlError - >)queryResult; - var addr = addresses.Value[0]; - - var result = await tx.UpdateAddressAsync( - addr.Id, - addr.CustomerId, - "200 Updated Ave", - "UpdatedCity", - "UC", - "67890", - "USA" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); + Assert.Equal(1, Assert.IsType(result).Value); }) .ConfigureAwait(false); } @@ -306,39 +169,7 @@ await _connection "Pending" ) .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateOrdersAsync_WithValidData_ReturnsOk() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var queryResult = tx.Query( - sql: "SELECT Id, CustomerId FROM Orders LIMIT 1", - mapper: reader => (Id: reader.GetString(0), CustomerId: reader.GetString(1)) - ); - var orders = (Result, SqlError>.Ok< - IReadOnlyList<(string Id, string CustomerId)>, - SqlError - >)queryResult; - var order = orders.Value[0]; - - var result = await tx.UpdateOrdersAsync( - order.Id, - "ORD-UPDATED", - "2024-07-01", - order.CustomerId, - 1500.00, - "Completed" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); + Assert.Equal(1, Assert.IsType(result).Value); }) .ConfigureAwait(false); } @@ -369,155 +200,7 @@ await _connection 125.00 ) .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateOrderItemAsync_WithValidData_ReturnsOk() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var queryResult = tx.Query( - sql: "SELECT Id, OrderId FROM OrderItem LIMIT 1", - mapper: reader => (Id: reader.GetString(0), OrderId: reader.GetString(1)) - ); - var items = (Result, SqlError>.Ok< - IReadOnlyList<(string Id, string OrderId)>, - SqlError - >)queryResult; - var item = items.Value[0]; - - var result = await tx.UpdateOrderItemAsync( - item.Id, - item.OrderId, - "Updated Widget", - 10.0, - 50.00, - 500.00 - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateCustomerAsync_WithNonExistentId_ReturnsZeroRows() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var result = await tx.UpdateCustomerAsync( - "nonexistent-id", - "Updated", - "u@t.com", - "555-0000", - "2024-01-01" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - var ok = (Result.Ok)result; - Assert.Equal(0, ok.Value); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateInvoiceAsync_WithNonExistentId_ReturnsZeroRows() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var result = await tx.UpdateInvoiceAsync( - "nonexistent", - "INV-X", - "2024-01-01", - "X", - "x@t.com", - 0.0, - 0.0, - "n" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateAddressAsync_WithNonExistentId_ReturnsZeroRows() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var result = await tx.UpdateAddressAsync( - "nonexistent", - "cust-1", - "St", - "City", - "ST", - "00000", - "US" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateInvoiceLineAsync_WithNonExistentId_ReturnsZeroRows() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var result = await tx.UpdateInvoiceLineAsync( - "nonexistent", - "inv-1", - "Desc", - 1.0, - 10.0, - 10.0, - 0.0, - "n" - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); - }) - .ConfigureAwait(false); - } - - [Fact] - public async Task UpdateOrderItemAsync_WithNonExistentId_ReturnsZeroRows() - { - await SetupSchemaAndSeed().ConfigureAwait(false); - - await _connection - .Transact(async tx => - { - var result = await tx.UpdateOrderItemAsync( - "nonexistent", - "ord-1", - "Product", - 1.0, - 10.0, - 10.0 - ) - .ConfigureAwait(false); - Assert.True(result is Result.Ok); + Assert.Equal(1, Assert.IsType(result).Value); }) .ConfigureAwait(false); } @@ -593,6 +276,24 @@ FOREIGN KEY (OrderId) REFERENCES Orders (Id) await command.ExecuteNonQueryAsync().ConfigureAwait(false); } + // Implements [CI-DESLOP]: retain the result type and row-count assertions + // for each generated operation while sharing schema and transaction setup. + private async Task AssertAffectedRows( + Func> execute, + int expectedRows, + bool seed = false + ) + { + await (seed ? SetupSchemaAndSeed() : SetupSchema()).ConfigureAwait(false); + await _connection + .Transact(async transaction => + { + var result = await execute(transaction).ConfigureAwait(false); + Assert.Equal(expectedRows, Assert.IsType(result).Value); + }) + .ConfigureAwait(false); + } + private async Task SetupSchemaAndSeed() { await SetupSchema().ConfigureAwait(false); @@ -616,21 +317,6 @@ INSERT INTO OrderItem (Id, OrderId, ProductName, Quantity, Price, Subtotal) VALU await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); } - public void Dispose() - { - _connection?.Dispose(); - if (File.Exists(_dbPath)) - { - try - { - File.Delete(_dbPath); - } -#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort - catch (IOException) - { - /* File may be locked */ - } -#pragma warning restore CA1031 - } - } + public void Dispose() => + CoreCoverageTests.DisposeSqliteFixture(connection: _connection, dbPath: _dbPath); } diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.Relations.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.Relations.cs new file mode 100644 index 00000000..0055f964 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.Relations.cs @@ -0,0 +1,244 @@ +using System.Data.Common; +using Xunit; +using AffectedRowsOk = Outcome.Result.Ok< + int, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.DataProvider.Example.Tests; + +/// Shared generated operations coverage for related records. +public sealed partial class GeneratedOperationsPlatformTests +{ + /// Inserts, updates, and reads every generated related entity on all providers. + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task GeneratedRelatedOperations_PersistAndUpdateEveryEntity(string provider) => + await WithProviderAsync(provider, VerifyRelationsAsync).ConfigureAwait(false); + + private static async Task VerifyRelationsAsync( + DbConnection connection, + string provider, + string output, + string schema + ) + { + MigrateSchema(provider, output, RelationsSchema(provider, schema)); + var ids = new RelationIds( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid() + ); + using (var transaction = await connection.BeginTransactionAsync().ConfigureAwait(false)) + { + await InsertRelationsAsync(transaction, ids).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } + AssertInsertedRelations(connection, ids); + + using (var transaction = await connection.BeginTransactionAsync().ConfigureAwait(false)) + { + await UpdateRelationsAsync(transaction, ids).ConfigureAwait(false); + await transaction.CommitAsync().ConfigureAwait(false); + } + AssertUpdatedRelations(connection, ids); + } + + private static async Task InsertRelationsAsync(DbTransaction tx, RelationIds ids) + { + var customer = await tx.InsertCustomerAsync( + ids.Customer.ToString(), + "Acme Customer", + "acme@example.test", + "555-0100", + "2024-01-01" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(customer).Value); + var invoice = await tx.InsertInvoiceAsync( + ids.Invoice.ToString(), + "INV-001", + "2024-01-15", + "Acme Customer", + "billing@example.test", + 120.00, + null, + "Initial invoice" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(invoice).Value); + var line = await tx.InsertInvoiceLineAsync( + ids.InvoiceLine.ToString(), + ids.Invoice.ToString(), + "Initial line", + 2.0, + 60.00, + 120.00, + 0.0, + "Initial line note" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(line).Value); + var address = await tx.InsertAddressAsync( + ids.Address.ToString(), + ids.Customer.ToString(), + "100 First St", + "Sydney", + "NSW", + "2000", + "Australia" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(address).Value); + var order = await tx.InsertOrdersAsync( + ids.Order.ToString(), + "ORD-001", + "2024-01-20", + ids.Customer.ToString(), + 90.00, + "Pending" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(order).Value); + var item = await tx.InsertOrderItemAsync( + ids.OrderItem.ToString(), + ids.Order.ToString(), + "Widget", + 3.0, + 30.00, + 90.00 + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(item).Value); + } + + private static async Task UpdateRelationsAsync(DbTransaction tx, RelationIds ids) + { + var invoice = await tx.UpdateInvoiceAsync( + ids.Invoice.ToString(), + "INV-UPDATED", + "2025-02-15", + "Acme Updated", + "updated-billing@example.test", + 200.00, + 25.00, + "Updated invoice" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(invoice).Value); + var line = await tx.UpdateInvoiceLineAsync( + ids.InvoiceLine.ToString(), + ids.Invoice.ToString(), + "Updated line", + 4.0, + 50.00, + 200.00, + 5.0, + "Updated line note" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(line).Value); + var address = await tx.UpdateAddressAsync( + ids.Address.ToString(), + ids.Customer.ToString(), + "200 Second Ave", + "Melbourne", + "VIC", + "3000", + "Australia" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(address).Value); + var order = await tx.UpdateOrdersAsync( + ids.Order.ToString(), + "ORD-UPDATED", + "2025-02-20", + ids.Customer.ToString(), + 150.00, + "Completed" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(order).Value); + var item = await tx.UpdateOrderItemAsync( + ids.OrderItem.ToString(), + ids.Order.ToString(), + "Updated widget", + 5.0, + 30.00, + 150.00 + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(item).Value); + + var missing = Guid.NewGuid().ToString(); + var missingInvoice = await tx.UpdateInvoiceAsync( + missing, + "INV-MISSING", + "2025-02-15", + "Missing", + "missing@example.test", + 1.00, + null, + "Missing" + ) + .ConfigureAwait(false); + Assert.Equal(0, Assert.IsType(missingInvoice).Value); + var missingLine = await tx.UpdateInvoiceLineAsync( + missing, + ids.Invoice.ToString(), + "Missing", + 1.0, + 1.0, + 1.0, + 0.0, + "Missing" + ) + .ConfigureAwait(false); + Assert.Equal(0, Assert.IsType(missingLine).Value); + var missingAddress = await tx.UpdateAddressAsync( + missing, + ids.Customer.ToString(), + "Missing", + "Missing", + "NSW", + "0000", + "Australia" + ) + .ConfigureAwait(false); + Assert.Equal(0, Assert.IsType(missingAddress).Value); + var missingOrder = await tx.UpdateOrdersAsync( + missing, + "ORD-MISSING", + "2025-02-20", + ids.Customer.ToString(), + 1.00, + "Missing" + ) + .ConfigureAwait(false); + Assert.Equal(0, Assert.IsType(missingOrder).Value); + var missingItem = await tx.UpdateOrderItemAsync( + missing, + ids.Order.ToString(), + "Missing", + 1.0, + 1.0, + 1.0 + ) + .ConfigureAwait(false); + Assert.Equal(0, Assert.IsType(missingItem).Value); + } + + private sealed record RelationIds( + Guid Customer, + Guid Invoice, + Guid InvoiceLine, + Guid Address, + Guid Order, + Guid OrderItem + ); +} diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.RelationsAssertions.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.RelationsAssertions.cs new file mode 100644 index 00000000..f0c381bc --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.RelationsAssertions.cs @@ -0,0 +1,191 @@ +using System.Data.Common; +using System.Globalization; +using Xunit; + +namespace Nimblesite.DataProvider.Example.Tests; + +public sealed partial class GeneratedOperationsPlatformTests +{ + private static void AssertInsertedRelations(DbConnection connection, RelationIds ids) + { + AssertRow( + connection, + "Customer", + ("Id", ids.Customer), + ("CustomerName", "Acme Customer"), + ("Email", "acme@example.test") + ); + AssertRow( + connection, + "Invoice", + ("Id", ids.Invoice), + ("InvoiceNumber", "INV-001"), + ("TotalAmount", 120m), + ("DiscountAmount", null) + ); + AssertRow( + connection, + "InvoiceLine", + ("Id", ids.InvoiceLine), + ("InvoiceId", ids.Invoice), + ("Description", "Initial line"), + ("Quantity", 2m) + ); + AssertRow( + connection, + "Address", + ("Id", ids.Address), + ("CustomerId", ids.Customer), + ("Street", "100 First St"), + ("City", "Sydney") + ); + AssertRow( + connection, + "Orders", + ("Id", ids.Order), + ("CustomerId", ids.Customer), + ("OrderNumber", "ORD-001"), + ("Status", "Pending") + ); + AssertRow( + connection, + "OrderItem", + ("Id", ids.OrderItem), + ("OrderId", ids.Order), + ("ProductName", "Widget"), + ("Subtotal", 90m) + ); + } + + private static void AssertUpdatedRelations(DbConnection connection, RelationIds ids) + { + AssertRow( + connection, + "Customer", + ("Id", ids.Customer), + ("CustomerName", "Acme Customer"), + ("Email", "acme@example.test") + ); + AssertRow( + connection, + "Invoice", + ("Id", ids.Invoice), + ("InvoiceNumber", "INV-UPDATED"), + ("CustomerName", "Acme Updated"), + ("TotalAmount", 200m), + ("DiscountAmount", 25m), + ("Notes", "Updated invoice") + ); + AssertRow( + connection, + "InvoiceLine", + ("Id", ids.InvoiceLine), + ("InvoiceId", ids.Invoice), + ("Description", "Updated line"), + ("Quantity", 4m), + ("Amount", 200m) + ); + AssertRow( + connection, + "Address", + ("Id", ids.Address), + ("CustomerId", ids.Customer), + ("Street", "200 Second Ave"), + ("City", "Melbourne"), + ("ZipCode", "3000") + ); + AssertRow( + connection, + "Orders", + ("Id", ids.Order), + ("CustomerId", ids.Customer), + ("OrderNumber", "ORD-UPDATED"), + ("TotalAmount", 150m), + ("Status", "Completed") + ); + AssertRow( + connection, + "OrderItem", + ("Id", ids.OrderItem), + ("OrderId", ids.Order), + ("ProductName", "Updated widget"), + ("Quantity", 5m), + ("Subtotal", 150m) + ); + } + + private static void AssertRow( + DbConnection connection, + string table, + params (string Column, object? Expected)[] expected + ) + { + using var command = connection.CreateCommand(); + switch (table) + { + case "Customer": + command.CommandText = """SELECT * FROM "Customer" """; + break; + case "Invoice": + command.CommandText = """SELECT * FROM "Invoice" """; + break; + case "InvoiceLine": + command.CommandText = """SELECT * FROM "InvoiceLine" """; + break; + case "Address": + command.CommandText = """SELECT * FROM "Address" """; + break; + case "Orders": + command.CommandText = """SELECT * FROM "Orders" """; + break; + case "OrderItem": + command.CommandText = """SELECT * FROM "OrderItem" """; + break; + default: + Assert.Fail($"Unsupported generated table: {table}"); + break; + } + using var reader = command.ExecuteReader(); + Assert.True(reader.Read(), $"Missing {table} row"); + foreach (var (column, value) in expected) + { + AssertValue(reader, table, column, value); + } + Assert.False(reader.Read(), $"Unexpected second {table} row"); + } + + private static void AssertValue( + DbDataReader reader, + string table, + string column, + object? expected + ) + { + var index = reader.GetOrdinal(column); + if (expected is null) + { + Assert.True(reader.IsDBNull(index), $"Expected {table}.{column} to be NULL"); + return; + } + Assert.False(reader.IsDBNull(index), $"Unexpected NULL in {table}.{column}"); + var actual = reader.GetValue(index); + switch (expected) + { + case Guid guid: + Assert.Equal( + guid, + Guid.Parse(Convert.ToString(actual, CultureInfo.InvariantCulture) ?? "") + ); + break; + case decimal number: + Assert.Equal(number, Convert.ToDecimal(actual, CultureInfo.InvariantCulture)); + break; + case string value: + Assert.Equal(value, Convert.ToString(actual, CultureInfo.InvariantCulture)); + break; + default: + Assert.Fail($"Unsupported expected value for {table}.{column}"); + break; + } + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.RelationsSchema.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.RelationsSchema.cs new file mode 100644 index 00000000..59e5fa42 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.RelationsSchema.cs @@ -0,0 +1,129 @@ +using Nimblesite.DataProvider.Migration.Core; + +namespace Nimblesite.DataProvider.Example.Tests; + +public sealed partial class GeneratedOperationsPlatformTests +{ + private static SchemaDefinition RelationsSchema(string provider, string schema) => + new() + { + Name = "generated_relations", + Tables = + [ + CustomerTable(provider, schema), + EntityTable( + schema, + "Invoice", + [ + IdColumn(provider, "Id"), + TextColumn("InvoiceNumber"), + TextColumn("InvoiceDate"), + TextColumn("CustomerName"), + NullableTextColumn("CustomerEmail"), + DecimalColumn("TotalAmount"), + DecimalColumn("DiscountAmount", nullable: true), + NullableTextColumn("Notes"), + ] + ), + EntityTable( + schema, + "InvoiceLine", + [ + IdColumn(provider, "Id"), + IdColumn(provider, "InvoiceId"), + TextColumn("Description"), + DecimalColumn("Quantity"), + DecimalColumn("UnitPrice"), + DecimalColumn("Amount"), + DecimalColumn("DiscountPercentage", nullable: true), + NullableTextColumn("Notes"), + ], + [RelationForeignKey(schema, "InvoiceId", "Invoice")] + ), + EntityTable( + schema, + "Address", + [ + IdColumn(provider, "Id"), + IdColumn(provider, "CustomerId"), + TextColumn("Street"), + TextColumn("City"), + TextColumn("State"), + TextColumn("ZipCode"), + TextColumn("Country"), + ], + [RelationForeignKey(schema, "CustomerId", "Customer")] + ), + EntityTable( + schema, + "Orders", + [ + IdColumn(provider, "Id"), + TextColumn("OrderNumber"), + TextColumn("OrderDate"), + IdColumn(provider, "CustomerId"), + DecimalColumn("TotalAmount"), + TextColumn("Status"), + ], + [RelationForeignKey(schema, "CustomerId", "Customer")] + ), + EntityTable( + schema, + "OrderItem", + [ + IdColumn(provider, "Id"), + IdColumn(provider, "OrderId"), + TextColumn("ProductName"), + DecimalColumn("Quantity"), + DecimalColumn("Price"), + DecimalColumn("Subtotal"), + ], + [RelationForeignKey(schema, "OrderId", "Orders")] + ), + ], + }; + + private static TableDefinition EntityTable( + string schema, + string name, + ColumnDefinition[] columns, + ForeignKeyDefinition[]? foreignKeys = null + ) => + new() + { + Schema = schema, + Name = name, + Columns = columns, + PrimaryKey = new PrimaryKeyDefinition { Columns = ["Id"] }, + ForeignKeys = foreignKeys ?? [], + }; + + private static ForeignKeyDefinition RelationForeignKey( + string schema, + string column, + string table + ) => + new() + { + Columns = [column], + ReferencedSchema = schema, + ReferencedTable = table, + ReferencedColumns = ["Id"], + }; + + private static ColumnDefinition NullableTextColumn(string name) => + new() + { + Name = name, + Type = PortableTypes.Text, + IsNullable = true, + }; + + private static ColumnDefinition DecimalColumn(string name, bool nullable = false) => + new() + { + Name = name, + Type = PortableTypes.Decimal(18, 2), + IsNullable = nullable, + }; +} diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.cs new file mode 100644 index 00000000..3d64dce3 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/GeneratedOperationsPlatformTests.cs @@ -0,0 +1,337 @@ +using System.Collections.Immutable; +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using Nimblesite.DataProvider.Migration.Core; +using Npgsql; +using Testcontainers.MsSql; +using Testcontainers.PostgreSql; +using Xunit; +using AffectedRowsOk = Outcome.Result.Ok< + int, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.DataProvider.Example.Tests; + +/// Exercises generated customer operations against each supported database provider. +public sealed partial class GeneratedOperationsPlatformTests +{ + /// Inserts, updates, and reads customers on every database provider. + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task GeneratedCustomerOperations_PersistTwoCustomersAndOnlyUpdateTheTarget( + string provider + ) => await WithProviderAsync(provider, VerifyOperationsAsync).ConfigureAwait(false); + + private static async Task WithProviderAsync( + string provider, + Func verify + ) + { + if (provider == "sqlite") + { + await VerifySqliteAsync(verify).ConfigureAwait(false); + return; + } + if (provider == "postgres") + { + await VerifyPostgresAsync(verify).ConfigureAwait(false); + return; + } + Assert.Equal("sqlserver", provider); + await VerifySqlServerAsync(verify).ConfigureAwait(false); + } + + private static async Task VerifySqliteAsync( + Func verify + ) + { + var path = Path.Combine(Path.GetTempPath(), $"generated_ops_{Guid.NewGuid():N}.db"); + try + { + using var connection = new SqliteConnection($"Data Source={path}"); + await connection.OpenAsync().ConfigureAwait(false); + await verify(connection, "sqlite", path, "main").ConfigureAwait(false); + } + finally + { + File.Delete(path); + } + } + + private static async Task VerifyPostgresAsync( + Func verify + ) + { + var container = new PostgreSqlBuilder() + .WithDatabase("generated_ops") + .WithUsername("test") + .WithPassword("test") + .Build(); + try + { + await container.StartAsync().ConfigureAwait(false); + // An opened NpgsqlConnection drops the password from ConnectionString, so + // the migrate CLI gets the container's full connection string. + using var connection = new NpgsqlConnection(container.GetConnectionString()); + await connection.OpenAsync().ConfigureAwait(false); + await verify(connection, "postgres", container.GetConnectionString(), "public") + .ConfigureAwait(false); + } + finally + { + await container.DisposeAsync().ConfigureAwait(false); + } + } + + private static async Task VerifySqlServerAsync( + Func verify + ) + { + var container = new MsSqlBuilder().Build(); + try + { + await container.StartAsync().ConfigureAwait(false); + using var admin = new SqlConnection(container.GetConnectionString()); + await admin.OpenAsync().ConfigureAwait(false); + using var create = admin.CreateCommand(); + create.CommandText = "CREATE DATABASE [generated_ops]"; + await create.ExecuteNonQueryAsync().ConfigureAwait(false); + var builder = new SqlConnectionStringBuilder(container.GetConnectionString()) + { + InitialCatalog = "generated_ops", + }; + using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync().ConfigureAwait(false); + await verify(connection, "sqlserver", builder.ConnectionString, "dbo") + .ConfigureAwait(false); + } + finally + { + await container.DisposeAsync().ConfigureAwait(false); + } + } + + private static async Task VerifyOperationsAsync( + DbConnection connection, + string provider, + string output, + string schema + ) + { + MigrateCustomerSchema(provider, output, schema); + var firstId = Guid.NewGuid(); + var secondId = Guid.NewGuid(); + + using (var transaction = await connection.BeginTransactionAsync().ConfigureAwait(false)) + { + var first = await transaction + .InsertCustomerAsync( + firstId.ToString(), + "Alpha Customer", + "alpha@example.test", + "555-0001", + "2024-01-01" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(first).Value); + var second = await transaction + .InsertCustomerAsync( + secondId.ToString(), + "Beta Customer", + "beta@example.test", + "555-0002", + "2024-02-02" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(second).Value); + await transaction.CommitAsync().ConfigureAwait(false); + } + Assert.Collection( + ReadCustomers(connection), + actual => + Assert.Equal( + new CustomerSnapshot( + firstId, + "Alpha Customer", + "alpha@example.test", + "555-0001", + "2024-01-01" + ), + actual + ), + actual => + Assert.Equal( + new CustomerSnapshot( + secondId, + "Beta Customer", + "beta@example.test", + "555-0002", + "2024-02-02" + ), + actual + ) + ); + + using (var transaction = await connection.BeginTransactionAsync().ConfigureAwait(false)) + { + var updated = await transaction + .UpdateCustomerAsync( + firstId.ToString(), + "Alpha Updated", + "updated@example.test", + "555-9999", + "2025-03-03" + ) + .ConfigureAwait(false); + Assert.Equal(1, Assert.IsType(updated).Value); + var missing = await transaction + .UpdateCustomerAsync( + Guid.NewGuid().ToString(), + "Missing Customer", + "missing@example.test", + "555-9998", + "2025-04-04" + ) + .ConfigureAwait(false); + Assert.Equal(0, Assert.IsType(missing).Value); + await transaction.CommitAsync().ConfigureAwait(false); + } + Assert.Collection( + ReadCustomers(connection), + actual => + Assert.Equal( + new CustomerSnapshot( + firstId, + "Alpha Updated", + "updated@example.test", + "555-9999", + "2025-03-03" + ), + actual + ), + actual => + Assert.Equal( + new CustomerSnapshot( + secondId, + "Beta Customer", + "beta@example.test", + "555-0002", + "2024-02-02" + ), + actual + ) + ); + } + + private static void MigrateCustomerSchema(string provider, string output, string schemaName) => + MigrateSchema( + provider, + output, + new SchemaDefinition + { + Name = "generated_operations", + Tables = [CustomerTable(provider, schemaName)], + } + ); + + private static TableDefinition CustomerTable(string provider, string schemaName) => + new() + { + Schema = schemaName, + Name = "Customer", + Columns = + [ + IdColumn(provider, "Id"), + TextColumn("CustomerName"), + TextColumn("Email"), + TextColumn("Phone"), + TextColumn("CreatedDate"), + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["Id"] }, + }; + + private static void MigrateSchema(string provider, string output, SchemaDefinition schema) + { + var schemaPath = Path.GetTempFileName(); + try + { + File.WriteAllText(schemaPath, SchemaYamlSerializer.ToYaml(schema)); + var exitCode = DataProviderMigrate.Program.Main([ + "migrate", + "--schema", + schemaPath, + "--provider", + provider, + "--output", + output, + ]); + Assert.True( + exitCode == 0, + $"{provider} schema migration failed with exit code {exitCode}" + ); + } + finally + { + File.Delete(schemaPath); + } + } + + private static ColumnDefinition TextColumn(string name) => + new() + { + Name = name, + Type = PortableTypes.Text, + IsNullable = false, + }; + + // The generated operations bind ids as strings: the Example schema they are generated + // from declares text ids. PostgreSQL has no implicit text -> uuid assignment cast, so + // the ids are text columns there; SQLite and SQL Server convert the strings to UUIDs. + private static ColumnDefinition IdColumn(string provider, string name) => + provider == "postgres" ? TextColumn(name) : UuidColumn(name); + + private static ColumnDefinition UuidColumn(string name) => + new() + { + Name = name, + Type = PortableTypes.Uuid, + IsNullable = false, + }; + + private static ImmutableArray ReadCustomers(DbConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = + """SELECT "Id", "CustomerName", "Email", "Phone", "CreatedDate" FROM "Customer" ORDER BY "CustomerName" """; + using var reader = command.ExecuteReader(); + var rows = ImmutableArray.CreateBuilder(); + while (reader.Read()) + { + var rawId = Convert.ToString(reader.GetValue(0), CultureInfo.InvariantCulture); + Assert.True(Guid.TryParse(rawId, out var id), $"Invalid customer ID: {rawId}"); + rows.Add( + new CustomerSnapshot( + id, + reader.GetString(1), + reader.GetString(2), + reader.GetString(3), + reader.GetString(4) + ) + ); + } + return rows.ToImmutable(); + } + + private sealed record CustomerSnapshot( + Guid Id, + string Name, + string Email, + string Phone, + string CreatedDate + ); +} diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/Nimblesite.DataProvider.Example.Tests.csproj b/DataProvider/Nimblesite.DataProvider.Example.Tests/Nimblesite.DataProvider.Example.Tests.csproj index a201255f..6b8dd7da 100644 --- a/DataProvider/Nimblesite.DataProvider.Example.Tests/Nimblesite.DataProvider.Example.Tests.csproj +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/Nimblesite.DataProvider.Example.Tests.csproj @@ -18,11 +18,17 @@ all + + + + + + diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/ProgramCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/ProgramCoverageTests.cs index 91950f6e..7cd48108 100644 --- a/DataProvider/Nimblesite.DataProvider.Example.Tests/ProgramCoverageTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/ProgramCoverageTests.cs @@ -604,21 +604,6 @@ INSERT INTO Customer (Id, CustomerName, Email, Phone, CreatedDate) VALUES await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); } - public void Dispose() - { - _connection?.Dispose(); - if (File.Exists(_dbPath)) - { - try - { - File.Delete(_dbPath); - } -#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort - catch (IOException) - { - /* File may be locked */ - } -#pragma warning restore CA1031 - } - } + public void Dispose() => + CoreCoverageTests.DisposeSqliteFixture(connection: _connection, dbPath: _dbPath); } diff --git a/DataProvider/Nimblesite.DataProvider.Example.Tests/SqlModelCoverageTests.cs b/DataProvider/Nimblesite.DataProvider.Example.Tests/SqlModelCoverageTests.cs index f33a8792..1a6b60f9 100644 --- a/DataProvider/Nimblesite.DataProvider.Example.Tests/SqlModelCoverageTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Example.Tests/SqlModelCoverageTests.cs @@ -991,21 +991,6 @@ INSERT INTO OrderItem (Id, OrderId, ProductName, Quantity, Price, Subtotal) VALU await insertCommand.ExecuteNonQueryAsync().ConfigureAwait(false); } - public void Dispose() - { - _connection?.Dispose(); - if (File.Exists(_dbPath)) - { - try - { - File.Delete(_dbPath); - } -#pragma warning disable CA1031 // Do not catch general exception types - file cleanup is best-effort - catch (IOException) - { - /* File may be locked */ - } -#pragma warning restore CA1031 - } - } + public void Dispose() => + CoreCoverageTests.DisposeSqliteFixture(connection: _connection, dbPath: _dbPath); } diff --git a/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj index facd4cb0..5da7df5a 100644 --- a/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj +++ b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj @@ -83,7 +83,7 @@ diff --git a/DataProvider/Nimblesite.DataProvider.SQLite/Nimblesite.DataProvider.SQLite.csproj b/DataProvider/Nimblesite.DataProvider.SQLite/Nimblesite.DataProvider.SQLite.csproj index 3ef4e67a..6f2105c5 100644 --- a/DataProvider/Nimblesite.DataProvider.SQLite/Nimblesite.DataProvider.SQLite.csproj +++ b/DataProvider/Nimblesite.DataProvider.SQLite/Nimblesite.DataProvider.SQLite.csproj @@ -54,6 +54,7 @@ SqlParserContractTests which need to walk the raw parse tree. --> + diff --git a/DataProvider/Nimblesite.DataProvider.SQLite/SqliteCodeGenerator.cs b/DataProvider/Nimblesite.DataProvider.SQLite/SqliteCodeGenerator.cs index 86c0895f..eab7eb13 100644 --- a/DataProvider/Nimblesite.DataProvider.SQLite/SqliteCodeGenerator.cs +++ b/DataProvider/Nimblesite.DataProvider.SQLite/SqliteCodeGenerator.cs @@ -140,7 +140,10 @@ string tableName var notNull = reader.GetInt32(3) == 1; // notnull column var isPrimaryKey = reader.GetInt32(5) > 0; // pk column - var csharpType = MapSqliteTypeToCSharpType(sqliteType, !notNull); + var csharpType = SqliteTypeMapper.MapSqliteTypeToCSharpType( + sqliteType, + !notNull + ); columns.Add( new DatabaseColumn @@ -186,37 +189,6 @@ string tableName } } - /// - /// Maps SQLite types to C# types - /// - private static string MapSqliteTypeToCSharpType(string sqliteType, bool isNullable) - { - var baseType = sqliteType.ToUpperInvariant() switch - { - var t when t.Contains("INT", StringComparison.OrdinalIgnoreCase) => "long", - var t - when t.Contains("REAL", StringComparison.OrdinalIgnoreCase) - || t.Contains("FLOAT", StringComparison.OrdinalIgnoreCase) - || t.Contains("DOUBLE", StringComparison.OrdinalIgnoreCase) => "double", - var t - when t.Contains("DECIMAL", StringComparison.OrdinalIgnoreCase) - || t.Contains("NUMERIC", StringComparison.OrdinalIgnoreCase) => "double", - var t when t.Contains("BOOL", StringComparison.OrdinalIgnoreCase) => "bool", - var t - when t.Contains("DATE", StringComparison.OrdinalIgnoreCase) - || t.Contains("TIME", StringComparison.OrdinalIgnoreCase) => "string", // SQLite stores dates as text - var t when t.Contains("BLOB", StringComparison.OrdinalIgnoreCase) => "byte[]", - _ => "string", - }; - - if (isNullable && baseType != "string" && baseType != "byte[]") - { - return baseType + "?"; - } - - return baseType; - } - // ============================= // Incremental generator wiring // ============================= @@ -514,12 +486,7 @@ as Result, SqlError>.Error< var text = sqlFile.GetText(context.CancellationToken) ?? SourceText.From(sqlText, Encoding.UTF8); - var span = new TextSpan(0, Math.Min(1, text.Length)); - var lineSpan = new LinePositionSpan( - new LinePosition(0, 0), - new LinePosition(0, Math.Min(1, text.Length)) - ); - var location = Location.Create(sqlFile.Path, span, lineSpan); + var location = CreateSqlFileLocation(sqlFile, text); var diagCol = Diagnostic.Create( new DiagnosticDescriptor( @@ -595,12 +562,7 @@ as Result, SqlError>.Error< var text = sqlFile.GetText(context.CancellationToken) ?? SourceText.From(sqlText, Encoding.UTF8); - var span = new TextSpan(0, Math.Min(1, text.Length)); - var lineSpan = new LinePositionSpan( - new LinePosition(0, 0), - new LinePosition(0, Math.Min(1, text.Length)) - ); - var location = Location.Create(sqlFile.Path, span, lineSpan); + var location = CreateSqlFileLocation(sqlFile, text); var diagGen = Diagnostic.Create( new DiagnosticDescriptor( @@ -624,12 +586,7 @@ as Result, SqlError>.Error< var text = sqlFile.GetText(context.CancellationToken) ?? SourceText.From(string.Empty, Encoding.UTF8); - var span = new TextSpan(0, Math.Min(1, text.Length)); - var lineSpan = new LinePositionSpan( - new LinePosition(0, 0), - new LinePosition(0, Math.Min(1, text.Length)) - ); - var location = Location.Create(sqlFile.Path, span, lineSpan); + var location = CreateSqlFileLocation(sqlFile, text); var diag = Diagnostic.Create( new DiagnosticDescriptor( @@ -770,4 +727,17 @@ is Result.Error operationsFailure } } } + + /// + /// Builds a diagnostic pointing at the start of a .sql additional file. + /// + private static Location CreateSqlFileLocation(AdditionalText sqlFile, SourceText text) + { + var span = new TextSpan(0, Math.Min(1, text.Length)); + var lineSpan = new LinePositionSpan( + new LinePosition(0, 0), + new LinePosition(0, Math.Min(1, text.Length)) + ); + return Location.Create(sqlFile.Path, span, lineSpan); + } } diff --git a/DataProvider/Nimblesite.DataProvider.SQLite/SqliteTypeMapper.cs b/DataProvider/Nimblesite.DataProvider.SQLite/SqliteTypeMapper.cs new file mode 100644 index 00000000..1180dbee --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.SQLite/SqliteTypeMapper.cs @@ -0,0 +1,41 @@ +namespace Nimblesite.DataProvider.SQLite; + +/// +/// Maps SQLite declared column types to C# type names. +/// +internal static class SqliteTypeMapper +{ + /// + /// Maps a SQLite declared type to a C# type name, appending "?" when nullable. + /// + /// The SQLite declared column type. + /// Whether the column is nullable. + /// The mapped C# type name. + internal static string MapSqliteTypeToCSharpType(string sqliteType, bool isNullable) + { + var baseType = sqliteType.ToUpperInvariant() switch + { + var t when t.Contains("INT", StringComparison.OrdinalIgnoreCase) => "long", + var t + when t.Contains("REAL", StringComparison.OrdinalIgnoreCase) + || t.Contains("FLOAT", StringComparison.OrdinalIgnoreCase) + || t.Contains("DOUBLE", StringComparison.OrdinalIgnoreCase) => "double", + var t + when t.Contains("DECIMAL", StringComparison.OrdinalIgnoreCase) + || t.Contains("NUMERIC", StringComparison.OrdinalIgnoreCase) => "double", + var t when t.Contains("BOOL", StringComparison.OrdinalIgnoreCase) => "bool", + var t + when t.Contains("DATE", StringComparison.OrdinalIgnoreCase) + || t.Contains("TIME", StringComparison.OrdinalIgnoreCase) => "string", // SQLite stores dates as text + var t when t.Contains("BLOB", StringComparison.OrdinalIgnoreCase) => "byte[]", + _ => "string", + }; + + if (isNullable && baseType != "string" && baseType != "byte[]") + { + return baseType + "?"; + } + + return baseType; + } +} diff --git a/DataProvider/Nimblesite.DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs b/DataProvider/Nimblesite.DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs new file mode 100644 index 00000000..45707d92 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.SqlServer/DataProviderIncrementalSourceGenerator.cs @@ -0,0 +1,32 @@ +using Microsoft.CodeAnalysis; + +namespace Nimblesite.DataProvider.SqlServer; + +/// +/// Compatibility generator that directs consumers to CLI-based code generation. +/// It does not read legacy configuration or emit sources. Implements [CON-NOCONFIG]. +/// +[Generator] +public class DataProviderIncrementalSourceGenerator : IIncrementalGenerator +{ + /// Registers the informational CLI delegation diagnostic. + /// The initialization context provided by Roslyn. + public void Initialize(IncrementalGeneratorInitializationContext context) => + context.RegisterSourceOutput( + context.CompilationProvider, + static (production, _) => + production.ReportDiagnostic( + Diagnostic.Create( + descriptor: new DiagnosticDescriptor( + id: "DataProvider005", + title: "Source generator delegated to CLI", + messageFormat: "Code generation is handled by CLI in MSBuild target, not by this incremental generator", + category: "DataProvider", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ), + location: Location.None + ) + ) + ); +} diff --git a/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnectionTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnectionTests.cs index c68c9545..f47bbe22 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnectionTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/Fakes/FakeDbConnectionTests.cs @@ -2,6 +2,8 @@ #pragma warning disable CA2000 // Dispose objects before losing scope #pragma warning disable CA1849 // Synchronous blocking calls +using System.Data.Common; + namespace Nimblesite.DataProvider.Tests.Fakes; public class FakeDbConnectionTests @@ -134,20 +136,8 @@ var s when s.Contains("SELECT COUNT(*) FROM Users", StringComparison.Ordinal) => [Fact] public void FakeCommand_UsesDataReaderFactory() { - // Arrange - var connection = new FakeDbConnection(sql => new FakeDataReader( - ["Id", "Name"], - [typeof(int), typeof(string)], - [ - [1, "Test User"], - ] - )); - - connection.Open(); - - // Act - using var command = connection.CreateCommand(); - command.CommandText = "SELECT * FROM Users"; + // Arrange & Act + using var command = CreateTestUserCommand(); using var reader = command.ExecuteReader(); // Assert @@ -184,7 +174,19 @@ public void FakeDataReader_HandlesNullValues() [Fact] public async Task FakeDataReader_SupportsAsyncOperations() { - // Arrange + // Arrange & Act + using var command = CreateTestUserCommand(); + using var reader = await command.ExecuteReaderAsync(); + + // Assert + Assert.True(await reader.ReadAsync()); + Assert.Equal(1, reader.GetInt32(0)); + Assert.Equal("Test User", reader.GetString(1)); + Assert.False(await reader.IsDBNullAsync(0)); + } + + private static DbCommand CreateTestUserCommand() + { var connection = new FakeDbConnection(sql => new FakeDataReader( ["Id", "Name"], [typeof(int), typeof(string)], @@ -195,15 +197,8 @@ public async Task FakeDataReader_SupportsAsyncOperations() connection.Open(); - // Act - using var command = connection.CreateCommand(); + var command = connection.CreateCommand(); command.CommandText = "SELECT * FROM Users"; - using var reader = await command.ExecuteReaderAsync(); - - // Assert - Assert.True(await reader.ReadAsync()); - Assert.Equal(1, reader.GetInt32(0)); - Assert.Equal("Test User", reader.GetString(1)); - Assert.False(await reader.IsDBNullAsync(0)); + return command; } } diff --git a/DataProvider/Nimblesite.DataProvider.Tests/LqlSqliteE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/LqlSqliteE2ETests.cs index 3553229c..b300d652 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/LqlSqliteE2ETests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/LqlSqliteE2ETests.cs @@ -12,31 +12,15 @@ namespace Nimblesite.DataProvider.Tests; /// public sealed class LqlSqliteE2ETests : IDisposable { - private readonly string _dbPath = Path.Combine( - Path.GetTempPath(), - $"lql_e2e_{Guid.NewGuid()}.db" - ); - - private readonly SqliteConnection _connection; + private readonly SqliteTestDatabase _db = new("lql_e2e"); + private SqliteConnection _connection => _db.Connection; public LqlSqliteE2ETests() { - _connection = new SqliteConnection($"Data Source={_dbPath}"); - _connection.Open(); CreateSchemaAndSeed(); } - public void Dispose() - { - _connection.Dispose(); - try - { - File.Delete(_dbPath); - } - catch (IOException) - { /* cleanup best-effort */ - } - } + public void Dispose() => _db.Dispose(); private void CreateSchemaAndSeed() { diff --git a/DataProvider/Nimblesite.DataProvider.Tests/Nimblesite.DataProvider.Tests.csproj b/DataProvider/Nimblesite.DataProvider.Tests/Nimblesite.DataProvider.Tests.csproj index eb3404e9..741a9043 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/Nimblesite.DataProvider.Tests.csproj +++ b/DataProvider/Nimblesite.DataProvider.Tests/Nimblesite.DataProvider.Tests.csproj @@ -22,14 +22,22 @@ + + + + + + + + diff --git a/DataProvider/Nimblesite.DataProvider.Tests/PostgresSqlModelIdentifierTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/PostgresSqlModelIdentifierTests.cs new file mode 100644 index 00000000..1d04faba --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/PostgresSqlModelIdentifierTests.cs @@ -0,0 +1,21 @@ +using Nimblesite.Lql.Postgres; +using Nimblesite.Sql.Model; + +namespace Nimblesite.DataProvider.Tests; + +public sealed class PostgresSqlModelIdentifierTests +{ + [Fact] + public void RenderSelectStatement_QuotesMixedCaseIdentifiers() + { + var statement = new SelectStatementBuilder() + .AddSelectColumn("Id") + .AddTable("Users") + .Build(); + + var sql = Assert.IsType(statement.ToPostgreSql()).Value; + + Assert.Contains("SELECT \"Id\"", sql, StringComparison.Ordinal); + Assert.Contains("FROM \"Users\"", sql, StringComparison.Ordinal); + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderDatabaseContainers.cs b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderDatabaseContainers.cs new file mode 100644 index 00000000..6b619d23 --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderDatabaseContainers.cs @@ -0,0 +1,50 @@ +using Testcontainers.MsSql; +using Testcontainers.PostgreSql; + +namespace Nimblesite.DataProvider.Tests; + +public sealed record QueryBuilderDatabaseContainers : IAsyncLifetime +{ + private readonly Lazy> _postgres = new(StartPostgresAsync); + private readonly Lazy> _sqlServer = new(StartSqlServerAsync); + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_postgres.IsValueCreated && _postgres.Value.IsCompletedSuccessfully) + { + var postgres = await _postgres.Value.ConfigureAwait(false); + await postgres.DisposeAsync().ConfigureAwait(false); + } + if (_sqlServer.IsValueCreated && _sqlServer.Value.IsCompletedSuccessfully) + { + var sqlServer = await _sqlServer.Value.ConfigureAwait(false); + await sqlServer.DisposeAsync().ConfigureAwait(false); + } + } + + public async Task GetConnectionStringAsync(string provider) => + provider == "postgres" + ? (await _postgres.Value.ConfigureAwait(false)).GetConnectionString() + : (await _sqlServer.Value.ConfigureAwait(false)).GetConnectionString(); + + private static async Task StartPostgresAsync() + { + var container = new PostgreSqlBuilder() + .WithImage("pgvector/pgvector:pg16") + .WithDatabase("postgres") + .WithUsername("test") + .WithPassword("test") + .Build(); + await container.StartAsync().ConfigureAwait(false); + return container; + } + + private static async Task StartSqlServerAsync() + { + var container = new MsSqlBuilder().Build(); + await container.StartAsync().ConfigureAwait(false); + return container; + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderE2ETests.cs index 086f6b7e..5503c055 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderE2ETests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderE2ETests.cs @@ -12,31 +12,17 @@ namespace Nimblesite.DataProvider.Tests; /// public sealed class QueryBuilderE2ETests : IDisposable { - private readonly string _dbPath = Path.Combine( - Path.GetTempPath(), - $"qb_e2e_{Guid.NewGuid()}.db" - ); + private readonly SqliteTestDatabase _db = new("qb_e2e"); + private SqliteConnection _connection => _db.Connection; - private readonly SqliteConnection _connection; + internal SqliteConnection Connection => _connection; public QueryBuilderE2ETests() { - _connection = new SqliteConnection($"Data Source={_dbPath}"); - _connection.Open(); CreateSchemaAndSeed(); } - public void Dispose() - { - _connection.Dispose(); - try - { - File.Delete(_dbPath); - } - catch (IOException) - { /* cleanup best-effort */ - } - } + public void Dispose() => _db.Dispose(); private void CreateSchemaAndSeed() { diff --git a/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderPlatformE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderPlatformE2ETests.cs new file mode 100644 index 00000000..d585a11c --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/QueryBuilderPlatformE2ETests.cs @@ -0,0 +1,246 @@ +using System.Collections.Immutable; +using System.Data; +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.SqlClient; +using Nimblesite.Sql.Model; +using Npgsql; +using NpgsqlTypes; +using NamesOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok, Nimblesite.Sql.Model.SqlError>; +using ProductRowsOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList<(string Name, decimal Price)>, + Nimblesite.Sql.Model.SqlError +>.Ok< + System.Collections.Generic.IReadOnlyList<(string Name, decimal Price)>, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.DataProvider.Tests; + +public sealed record QueryBuilderPlatformE2ETests : IClassFixture +{ + private readonly QueryBuilderDatabaseContainers _containers; + + public QueryBuilderPlatformE2ETests(QueryBuilderDatabaseContainers containers) + { + _containers = containers; + } + + private static readonly ImmutableArray<( + string Name, + decimal Price, + string Category + )> Products = + [ + ("Widget A", 10m, "Electronics"), + ("Widget B", 25.5m, "Electronics"), + ("Gadget X", 99.99m, "Gadgets"), + ("Gadget Y", 149.99m, "Gadgets"), + ("Tool Alpha", 35m, "Tools"), + ("Tool Beta", 45m, "Tools"), + ("Tool Gamma", 15m, "Tools"), + ("Premium Widget", 500m, "Electronics"), + ]; + + // Implements [LQL-OUTPUT-DIALECTS]. The same builder and model queries must execute on all platforms. + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task ProductFilterAndPagination_ReturnExactRows(string provider) + { + if (provider == "sqlite") + { + using var seeded = new QueryBuilderE2ETests(); + AssertProductQueries(seeded.Connection, provider); + return; + } + + var connectionString = await _containers + .GetConnectionStringAsync(provider) + .ConfigureAwait(true); + CreateProductSchema(provider, connectionString); + if (provider == "postgres") + { + using var postgres = new NpgsqlConnection(connectionString); + await postgres.OpenAsync().ConfigureAwait(true); + SeedPostgres(postgres); + AssertProductQueries(postgres, provider); + return; + } + Assert.Equal("sqlserver", provider); + using var sqlServer = new SqlConnection(connectionString); + await sqlServer.OpenAsync().ConfigureAwait(true); + SeedSqlServer(sqlServer); + AssertProductQueries(sqlServer, provider); + } + + private static void AssertProductQueries(DbConnection connection, string provider) + { + var filtered = "products" + .From() + .Select(columns: [(null, "name"), (null, "price")]) + .Where(columnName: "category", value: "Tools") + .OrderBy(columnName: "price") + .Take(count: 2) + .ToSqlStatement(); + var toolSql = GenerateSql(filtered, provider); + var tools = ReadProducts(connection, toolSql); + Assert.Equal(2, tools.Length); + Assert.Equal(("Tool Gamma", 15m), tools[0]); + Assert.Equal(("Tool Alpha", 35m), tools[1]); + AssertProductPages(connection, provider); + } + + private static void AssertProductPages(DbConnection connection, string provider) + { + var first = ReadNames(connection, ModelPageSql(provider, offset: 0)); + var second = ReadNames(connection, ModelPageSql(provider, offset: 3)); + var third = ReadNames(connection, ModelPageSql(provider, offset: 6)); + Assert.Equal(3, first.Length); + Assert.Equal("Gadget X", first[0]); + Assert.Equal("Gadget Y", first[1]); + Assert.Equal("Premium Widget", first[2]); + Assert.Equal(3, second.Length); + Assert.Equal("Tool Alpha", second[0]); + Assert.Equal("Tool Beta", second[1]); + Assert.Equal("Tool Gamma", second[2]); + Assert.Equal(2, third.Length); + Assert.Equal("Widget A", third[0]); + Assert.Equal("Widget B", third[1]); + Assert.Equal(8, first.Length + second.Length + third.Length); + Assert.Equal(8, first.Concat(second).Concat(third).Distinct().Count()); + } + + private static string ModelPageSql(string provider, int offset) + { + var statement = new SelectStatementBuilder() + .AddTable(name: "products") + .AddSelectColumn(name: "name") + .AddOrderBy(column: "name", direction: "ASC") + .WithLimit("3") + .WithOffset(offset.ToString(CultureInfo.InvariantCulture)) + .Build(); + var sql = GenerateSql(statement, provider); + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("products", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + provider == "sqlserver" ? "OFFSET" : "LIMIT", + sql, + StringComparison.OrdinalIgnoreCase + ); + return sql; + } + + private static string GenerateSql(SelectStatement statement, string provider) => + SqlStatementGenerationTests.RenderForProvider(statement, provider); + + private static ImmutableArray<(string Name, decimal Price)> ReadProducts( + DbConnection connection, + string sql + ) + { + var result = connection.Query<(string Name, decimal Price)>( + sql: sql, + mapper: reader => + ( + reader.GetString(0), + Convert.ToDecimal(reader.GetValue(1), CultureInfo.InvariantCulture) + ) + ); + return Assert.IsType(result).Value.ToImmutableArray(); + } + + private static ImmutableArray ReadNames(DbConnection connection, string sql) + { + var result = connection.Query(sql: sql, mapper: reader => reader.GetString(0)); + return Assert.IsType(result).Value.ToImmutableArray(); + } + + private static void CreateProductSchema(string provider, string connectionString) + { + var schema = provider == "postgres" ? "public" : "dbo"; + var yaml = $$""" + name: query_builder_platform + tables: + - name: products + schema: {{schema}} + columns: + - name: id + type: Uuid + isNullable: false + - name: name + type: Text + isNullable: false + - name: price + type: Decimal(10,2) + isNullable: false + - name: category + type: Text + isNullable: false + primaryKey: + columns: + - id + """; + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, yaml); + Assert.Equal( + 0, + DataProviderMigrate.Program.Main([ + "migrate", + "--schema", + path, + "--provider", + provider, + "--output", + connectionString, + ]) + ); + } + finally + { + File.Delete(path); + } + } + + private static Guid ProductId(int index) => + Guid.Parse($"00000000-0000-0000-0000-{index + 1:000000000000}"); + + private static void SeedPostgres(NpgsqlConnection connection) + { + using var importer = connection.BeginBinaryImport( + "COPY products (id, name, price, category) FROM STDIN (FORMAT BINARY)" + ); + for (var index = 0; index < Products.Length; index++) + { + var product = Products[index]; + importer.StartRow(); + importer.Write(ProductId(index), NpgsqlDbType.Uuid); + importer.Write(product.Name, NpgsqlDbType.Text); + importer.Write(product.Price, NpgsqlDbType.Numeric); + importer.Write(product.Category, NpgsqlDbType.Text); + } + Assert.Equal(Products.Length, Convert.ToInt32(importer.Complete())); + } + + private static void SeedSqlServer(SqlConnection connection) + { + using var table = new DataTable(); + table.Columns.Add("id", typeof(Guid)); + table.Columns.Add("name", typeof(string)); + table.Columns.Add("price", typeof(decimal)); + table.Columns.Add("category", typeof(string)); + for (var index = 0; index < Products.Length; index++) + { + var product = Products[index]; + table.Rows.Add(ProductId(index), product.Name, product.Price, product.Category); + } + using var bulk = new SqlBulkCopy(connection) { DestinationTableName = "dbo.products" }; + bulk.WriteToServer(table); + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SQLiteContextE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SQLiteContextE2ETests.cs index 4fb845b5..64c064c6 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/SQLiteContextE2ETests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SQLiteContextE2ETests.cs @@ -42,6 +42,22 @@ private static Pipeline WithIdentity(string table) private static Identifier Id(string name) => new(name); + private static Pipeline CountByCountryPipeline() + { + var pipeline = WithIdentity("users"); + pipeline.Steps.Add( + new SelectStep([ + ColumnInfo.Named("country"), + ColumnInfo.FromExpression("COUNT(*)", alias: "cnt"), + ]) + { + Base = Id("users"), + } + ); + pipeline.Steps.Add(new GroupByStep(["country"]) { Base = Id("users") }); + return pipeline; + } + private void CreateSchemaAndSeed() { using var cmd = _connection.CreateCommand(); @@ -119,17 +135,7 @@ public void ProcessPipeline_SelectFilterOrderLimit_GeneratesCorrectSql() [Fact] public void PipelineProcessor_GroupByWithHaving_GeneratesGroupByAndHavingSql() { - var pipeline = WithIdentity("users"); - pipeline.Steps.Add( - new SelectStep([ - ColumnInfo.Named("country"), - ColumnInfo.FromExpression("COUNT(*)", alias: "cnt"), - ]) - { - Base = Id("users"), - } - ); - pipeline.Steps.Add(new GroupByStep(["country"]) { Base = Id("users") }); + var pipeline = CountByCountryPipeline(); pipeline.Steps.Add(new HavingStep { Base = Id("users"), Condition = "COUNT(*) > 1" }); var context = new SQLiteContext(); @@ -346,17 +352,7 @@ is not Result, SqlError>.Ok, SqlErro [Fact] public void PipelineProcessor_GroupByExecute_ReturnsAggregatedRows() { - var pipeline = WithIdentity("users"); - pipeline.Steps.Add( - new SelectStep([ - ColumnInfo.Named("country"), - ColumnInfo.FromExpression("COUNT(*)", alias: "cnt"), - ]) - { - Base = Id("users"), - } - ); - pipeline.Steps.Add(new GroupByStep(["country"]) { Base = Id("users") }); + var pipeline = CountByCountryPipeline(); pipeline.Steps.Add(new OrderByStep([("cnt", "DESC")]) { Base = Id("users") }); var context = new SQLiteContext(); diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SelectStatementLinqE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SelectStatementLinqE2ETests.cs index 8c4d1f43..f565701d 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/SelectStatementLinqE2ETests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SelectStatementLinqE2ETests.cs @@ -20,31 +20,15 @@ private sealed record TestProduct public string Category { get; init; } = ""; } - private readonly string _dbPath = Path.Combine( - Path.GetTempPath(), - $"linq_e2e_{Guid.NewGuid()}.db" - ); - - private readonly SqliteConnection _connection; + private readonly SqliteTestDatabase _db = new("linq_e2e"); + private SqliteConnection _connection => _db.Connection; public SelectStatementLinqE2ETests() { - _connection = new SqliteConnection($"Data Source={_dbPath}"); - _connection.Open(); CreateSchemaAndSeed(); } - public void Dispose() - { - _connection.Dispose(); - try - { - File.Delete(_dbPath); - } - catch (IOException) - { /* cleanup best-effort */ - } - } + public void Dispose() => _db.Dispose(); private void CreateSchemaAndSeed() { @@ -83,9 +67,7 @@ public void WhereGeneric_SimpleEquality_ReturnsMatchingRows() .Where(p => p.Category == "Tools") .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("Category", sql); @@ -131,9 +113,7 @@ public void WhereGeneric_GreaterThanComparison_ReturnsMatchingRows() .OrderBy(columnName: "Price") .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains(">", sql); var queryResult = _connection.Query<(string Name, double Price)>( @@ -171,9 +151,7 @@ public void WhereGeneric_AndCondition_ReturnsMatchingRows() .OrderBy(columnName: "Price") .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); var queryResult = _connection.Query<(string Name, double Price)>( @@ -211,9 +189,7 @@ public void WhereGeneric_OrCondition_ReturnsMatchingRows() .OrderBy(columnName: "Price") .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("OR", sql, StringComparison.OrdinalIgnoreCase); var queryResult = _connection.Query<(string Name, double Price)>( @@ -255,9 +231,7 @@ public void WhereGeneric_StringContains_GeneratesLikeClause() .OrderBy(columnName: "Name") .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("LIKE", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("Widget", sql); Assert.Contains("Name", sql); @@ -273,9 +247,7 @@ public void WhereGeneric_StringStartsWith_GeneratesLikeClause() .OrderBy(columnName: "Name") .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("LIKE", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("Tool", sql); Assert.Contains("Name", sql); @@ -286,9 +258,7 @@ public void SelectGeneric_SingleColumn_GeneratesCorrectSQL() { var statement = "Products".From().Select(p => p.Name).ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("Name", sql); Assert.DoesNotContain("*", sql); Assert.Contains("FROM Products", sql); @@ -318,9 +288,7 @@ public void OrderByGeneric_SingleColumn_ReturnsSortedResults() .OrderBy(p => p.Price) .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("Price", sql); @@ -366,9 +334,7 @@ public void FullLinqChain_WhereSelectOrderByTakeSkip_ExecutesCorrectly() .Take(count: 3) .ToSqlStatement(); - var sqlResult = statement.ToSQLite(); - var sqlOk = Assert.IsType(sqlResult); - var sql = sqlOk.Value; + var sql = ToSqliteSql(statement); Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("LIMIT", sql, StringComparison.OrdinalIgnoreCase); @@ -405,4 +371,14 @@ public void FullLinqChain_WhereSelectOrderByTakeSkip_ExecutesCorrectly() Assert.Equal("Tool Beta", rows[2].Name); Assert.Equal(45.00, rows[2].Price); } + + /// + /// Convert a statement to SQLite SQL, asserting the result is Ok, and return the SQL text. + /// + private static string ToSqliteSql(SelectStatement statement) + { + var sqlResult = statement.ToSQLite(); + var sqlOk = Assert.IsType(sqlResult); + return sqlOk.Value; + } } diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SourceGeneratorDelegationTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SourceGeneratorDelegationTests.cs new file mode 100644 index 00000000..b9bc005c --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/SourceGeneratorDelegationTests.cs @@ -0,0 +1,28 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Nimblesite.DataProvider.SqlServer; + +namespace Nimblesite.DataProvider.Tests; + +public sealed record SourceGeneratorDelegationTests +{ + // Implements [CON-NOCONFIG]: the CLI owns generation; the legacy generator only informs. + [Fact] + public void PassiveGenerator_DoesNotRequireLegacyConfigurationOrEmitSources() + { + var compilation = CSharpCompilation.Create( + assemblyName: "CliConsumer", + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + var driver = CSharpGeneratorDriver + .Create(generators: [new DataProviderIncrementalSourceGenerator().AsSourceGenerator()]) + .RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + Assert.DoesNotContain( + diagnostics, + diagnostic => diagnostic.Severity == DiagnosticSeverity.Error + ); + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DataProvider005"); + Assert.Empty(driver.GetRunResult().GeneratedTrees); + Assert.Empty(output.GetDiagnostics()); + } +} diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SqlModelE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqlModelE2ETests.cs index 7c8c1f2c..98a6cd5a 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/SqlModelE2ETests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqlModelE2ETests.cs @@ -11,31 +11,15 @@ namespace Nimblesite.DataProvider.Tests; /// public sealed class SqlModelE2ETests : IDisposable { - private readonly string _dbPath = Path.Combine( - Path.GetTempPath(), - $"model_e2e_{Guid.NewGuid()}.db" - ); - - private readonly SqliteConnection _connection; + private readonly SqliteTestDatabase _db = new("model_e2e"); + private SqliteConnection _connection => _db.Connection; public SqlModelE2ETests() { - _connection = new SqliteConnection($"Data Source={_dbPath}"); - _connection.Open(); CreateSchemaAndSeed(); } - public void Dispose() - { - _connection.Dispose(); - try - { - File.Delete(_dbPath); - } - catch (IOException) - { /* cleanup best-effort */ - } - } + public void Dispose() => _db.Dispose(); private void CreateSchemaAndSeed() { diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SqlStatementGenerationTests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqlStatementGenerationTests.cs index 21d1f4cd..03e94318 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/SqlStatementGenerationTests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqlStatementGenerationTests.cs @@ -1,12 +1,56 @@ +using System.Reflection; +using Nimblesite.Lql.Postgres; using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; using Nimblesite.Sql.Model; namespace Nimblesite.DataProvider.Tests; public sealed class SqlStatementGenerationTests { - [Fact] - public void ToSQLite_SimpleSelectFromSingleTable_GeneratesExpectedSql() + // Implements [DP-SQL-MODEL-DIALECTS]. A missing provider renderer fails this same test. + private static void AssertDialect( + SelectStatement statement, + string provider, + params string[] fragments + ) + { + // Identifier quoting is provider syntax; PostgresSqlModelIdentifierTests pins it. + var sql = RenderForProvider(statement, provider) + .Replace("\"", "", StringComparison.Ordinal); + Assert.NotEmpty(sql); + foreach (var fragment in fragments) + { + Assert.Contains(fragment, sql, StringComparison.OrdinalIgnoreCase); + } + } + + internal static string RenderForProvider(SelectStatement statement, string provider) => + Assert.IsType(FindRenderer(provider).Invoke(null, [statement])).Value; + + private static MethodInfo FindRenderer(string provider) + { + var (extensionType, methodName) = provider switch + { + "sqlite" => (typeof(SqlStatementExtensionsSQLite), "ToSQLite"), + "postgres" => (typeof(SqlStatementExtensionsPostgreSQL), "ToPostgreSql"), + "sqlserver" => (typeof(SqlStatementExtensionsSqlServer), "ToSqlServer"), + _ => (typeof(SqlStatementExtensionsSQLite), "UnknownProvider"), + }; + return Assert.Single( + extensionType.GetMethods(), + method => + method.Name == methodName + && method.GetParameters() is [{ ParameterType: var inputType }] + && inputType == typeof(SelectStatement) + ); + } + + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_SimpleSelectFromSingleTable_GeneratesExpectedSql(string provider) { var stmt = new SelectStatementBuilder() .AddSelectColumn("Id") @@ -19,10 +63,14 @@ public void ToSQLite_SimpleSelectFromSingleTable_GeneratesExpectedSql() Assert.IsType(result); var sql = ((StringOk)result).Value; Assert.Equal("SELECT Id, Name FROM Users", sql); + AssertDialect(stmt, provider, "SELECT", "Id", "Name", "FROM Users"); } - [Fact] - public void ToSQLite_SelectAllWildcard_WhenNoColumnsSelected_GeneratesStar() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_SelectAllWildcard_WhenNoColumnsSelected_GeneratesStar(string provider) { var stmt = new SelectStatementBuilder().AddTable("Users").Build(); @@ -30,10 +78,14 @@ public void ToSQLite_SelectAllWildcard_WhenNoColumnsSelected_GeneratesStar() var success = Assert.IsType(result); Assert.Equal("SELECT * FROM Users", success.Value); + AssertDialect(stmt, provider, "SELECT *", "FROM Users"); } - [Fact] - public void ToSQLite_WithWhereComparison_FormatsCondition() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithWhereComparison_FormatsCondition(string provider) { var where = WhereCondition.Comparison( ColumnInfo.Named("Age"), @@ -48,10 +100,14 @@ public void ToSQLite_WithWhereComparison_FormatsCondition() var success = Assert.IsType(stmt.ToSQLite()); Assert.Equal("SELECT Id FROM Users WHERE Age > 18", success.Value); + AssertDialect(stmt, provider, "SELECT Id", "FROM Users", "WHERE", "Age > 18"); } - [Fact] - public void ToSQLite_WithWhereLogicalOperators_FormatsSequence() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithWhereLogicalOperators_FormatsSequence(string provider) { var stmt = new SelectStatementBuilder() .AddSelectColumn("Id") @@ -75,10 +131,14 @@ public void ToSQLite_WithWhereLogicalOperators_FormatsSequence() var success = Assert.IsType(stmt.ToSQLite()); Assert.Equal("SELECT Id FROM Users WHERE Age >= 18 AND Country = 'AU'", success.Value); + AssertDialect(stmt, provider, "WHERE", "Age >= 18", "AND", "Country = 'AU'"); } - [Fact] - public void ToSQLite_WithParenthesesInWhere_FormatsParens() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithParenthesesInWhere_FormatsParens(string provider) { var stmt = new SelectStatementBuilder() .AddSelectColumn("Id") @@ -111,10 +171,14 @@ public void ToSQLite_WithParenthesesInWhere_FormatsParens() "SELECT Id FROM Users WHERE ( Age >= 18 AND Age < 65 ) AND Active = 1", success.Value ); + AssertDialect(stmt, provider, "WHERE", "Age >= 18", "Age < 65", "AND Active = 1"); } - [Fact] - public void ToSQLite_WithJoin_OutputsInnerJoin() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithJoin_OutputsInnerJoin(string provider) { var stmt = new SelectStatementBuilder() .AddSelectColumn("Users.Id") @@ -130,10 +194,14 @@ public void ToSQLite_WithJoin_OutputsInnerJoin() "SELECT Users.Id, Orders.Total FROM Users INNER JOIN Orders ON Users.Id = Orders.UserId", success.Value ); + AssertDialect(stmt, provider, "FROM Users", "INNER JOIN Orders", "ON", "UserId"); } - [Fact] - public void ToSQLite_WithGroupByAndHaving_OutputsClauses() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithGroupByAndHaving_OutputsClauses(string provider) { var stmt = new SelectStatementBuilder() .AddSelectColumn("Country") @@ -148,10 +216,14 @@ public void ToSQLite_WithGroupByAndHaving_OutputsClauses() "SELECT Country, COUNT(*) AS Total FROM Users GROUP BY Country HAVING COUNT(*) > 10", success.Value ); + AssertDialect(stmt, provider, "COUNT(*)", "GROUP BY Country", "HAVING", "> 10"); } - [Fact] - public void ToSQLite_WithOrderBy_OutputsOrderBy() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithOrderBy_OutputsOrderBy(string provider) { var stmt = new SelectStatementBuilder() .AddSelectColumn("Id") @@ -162,10 +234,14 @@ public void ToSQLite_WithOrderBy_OutputsOrderBy() var success = Assert.IsType(stmt.ToSQLite()); Assert.Equal("SELECT Id FROM Users ORDER BY Name ASC, Id DESC", success.Value); + AssertDialect(stmt, provider, "ORDER BY", "Name ASC", "Id DESC"); } - [Fact] - public void ToSQLite_WithDistinctAndPaging_OutputsDistinctLimitOffset() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithDistinctAndPaging_OutputsDistinctLimitOffset(string provider) { var stmt = new SelectStatementBuilder() .WithDistinct(true) @@ -177,10 +253,14 @@ public void ToSQLite_WithDistinctAndPaging_OutputsDistinctLimitOffset() var success = Assert.IsType(stmt.ToSQLite()); Assert.Equal("SELECT DISTINCT Name FROM Users LIMIT 5 OFFSET 10", success.Value); + AssertDialect(stmt, provider, "SELECT DISTINCT", "Name", "5", "10"); } - [Fact] - public void ToSQLite_WithWildcardAndAlias_FormatsCorrectly() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public void AllDialects_WithWildcardAndAlias_FormatsCorrectly(string provider) { var stmt = new SelectStatementBuilder() .AddSelectColumn(ColumnInfo.Wildcard("u")) @@ -189,5 +269,6 @@ public void ToSQLite_WithWildcardAndAlias_FormatsCorrectly() var success = Assert.IsType(stmt.ToSQLite()); Assert.Equal("SELECT u.* FROM Users", success.Value); + AssertDialect(stmt, provider, "SELECT", "u.*", "FROM Users"); } } diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SqliteCrudE2ETests.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqliteCrudE2ETests.cs index b40748fb..ce465e6d 100644 --- a/DataProvider/Nimblesite.DataProvider.Tests/SqliteCrudE2ETests.cs +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqliteCrudE2ETests.cs @@ -9,31 +9,15 @@ namespace Nimblesite.DataProvider.Tests; /// public sealed class SqliteCrudE2ETests : IDisposable { - private readonly string _dbPath = Path.Combine( - Path.GetTempPath(), - $"crud_e2e_{Guid.NewGuid()}.db" - ); - - private readonly SqliteConnection _connection; + private readonly SqliteTestDatabase _db = new("crud_e2e"); + private SqliteConnection _connection => _db.Connection; public SqliteCrudE2ETests() { - _connection = new SqliteConnection($"Data Source={_dbPath}"); - _connection.Open(); CreateSchema(); } - public void Dispose() - { - _connection.Dispose(); - try - { - File.Delete(_dbPath); - } - catch (IOException) - { /* cleanup best-effort */ - } - } + public void Dispose() => _db.Dispose(); private void CreateSchema() { diff --git a/DataProvider/Nimblesite.DataProvider.Tests/SqliteTestDatabase.cs b/DataProvider/Nimblesite.DataProvider.Tests/SqliteTestDatabase.cs new file mode 100644 index 00000000..47b1d4fa --- /dev/null +++ b/DataProvider/Nimblesite.DataProvider.Tests/SqliteTestDatabase.cs @@ -0,0 +1,43 @@ +using Microsoft.Data.Sqlite; + +namespace Nimblesite.DataProvider.Tests; + +/// +/// Shared file-based SQLite database for E2E test fixtures: opens a temp-file +/// connection on construction and deletes the file on dispose. +/// +internal sealed class SqliteTestDatabase : IDisposable +{ + private readonly string _dbPath; + + /// + /// The open connection to the temp-file SQLite database. + /// + public SqliteConnection Connection { get; } + + /// + /// Creates and opens a temp-file SQLite database. + /// + /// Temp-file name prefix identifying the fixture. + public SqliteTestDatabase(string prefix) + { + _dbPath = Path.Combine(Path.GetTempPath(), $"{prefix}_{Guid.NewGuid()}.db"); + Connection = new SqliteConnection($"Data Source={_dbPath}"); + Connection.Open(); + } + + /// + /// Disposes the connection and best-effort deletes the temp file. + /// + public void Dispose() + { + Connection.Dispose(); + try + { + File.Delete(_dbPath); + } + catch (IOException) + { /* cleanup best-effort */ + } + } +} diff --git a/Directory.Build.props b/Directory.Build.props index a9f5ce20..34c7ff91 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,9 @@ false disabled false - 0.9.12-beta + + 0.0.0-dev ChristianFindlay Nimblesite MIT @@ -25,6 +27,9 @@ 9999 true true + + false + false All $(MSBuildThisFileDirectory)CodeAnalysis.ruleset true @@ -51,6 +56,9 @@ runtime; build; native; contentfiles; analyzers + + + - - - - all - runtime; build; native; contentfiles; analyzers - diff --git a/Docs/AIWorkScheduler.Spec.md b/Docs/AIWorkScheduler.Spec.md new file mode 100644 index 00000000..b0174d97 --- /dev/null +++ b/Docs/AIWorkScheduler.Spec.md @@ -0,0 +1,1123 @@ +# AI Work Scheduler Specification + +## Overview + +**AIWorkScheduler** is an intelligent, distributed job scheduling framework that combines traditional task queue mechanics (like Hangfire) with modern AI-driven optimization. The scheduler dynamically adjusts job priorities, reorders queues, and makes intelligent decisions based on real-time system state, historical performance, and predicted outcomes. + +### Design Goals + +1. **Self-Optimizing** – AI continuously learns from execution patterns to improve scheduling decisions +2. **Context-Aware** – Jobs carry rich context that AI uses for intelligent prioritization +3. **Deadline-Driven** – First-class support for SLAs and deadline constraints +4. **Resource-Aware** – Understands worker capabilities and resource costs +5. **Fault-Tolerant** – Graceful degradation, automatic retry with learned backoff +6. **Observable** – Rich telemetry for AI training and human debugging + +--- + +## Research Foundation + +This design incorporates cutting-edge scheduling research: + +| Research Area | Key Concepts | References | +|--------------|--------------|------------| +| **Reinforcement Learning for Scheduling** | Q-learning, Deep Q-Networks for dynamic priority adjustment | [DeepRM: Resource Management with Deep RL](https://people.csail.mit.edu/alizadeh/papers/deeprm-hotnets16.pdf) | +| **Multi-Objective Optimization** | Pareto-optimal scheduling considering latency, throughput, cost | [MOSAIC: Multi-Objective Scheduling](https://dl.acm.org/doi/10.1145/3297858.3304071) | +| **Decima (MIT/Microsoft)** | Graph Neural Networks for cluster scheduling | [Decima: Learning Scheduling Algorithms](https://web.mit.edu/decima/) | +| **Deadline-Aware Scheduling** | Earliest Deadline First (EDF) with slack management | [Real-Time Systems - Liu & Layland](https://dl.acm.org/doi/10.1145/321738.321743) | +| **Predictive Autoscaling** | LSTM/Transformer models for workload prediction | [Autopilot: Workload Autoscaling at Google](https://research.google/pubs/pub49174/) | +| **Queueing Theory** | M/G/1, Priority queues, Little's Law for capacity planning | [Performance Modeling - Mor Harchol-Balter](https://www.cs.cmu.edu/~harchol/PerformanceModeling/book.html) | +| **Chaos Engineering for Schedulers** | Fault injection to train resilient scheduling policies | [Netflix Chaos Engineering](https://netflixtechblog.com/chaos-engineering-upgraded-878d341f15fa) | + +--- + +## Core Concepts + +### Job + +A unit of work with rich metadata for AI decision-making. + +```csharp +/// Immutable job definition with AI context +public sealed record Job( + Guid Id, + string Queue, + string TypeName, + string SerializedArgs, + JobPriority BasePriority, + DateTimeOffset CreatedAt, + DateTimeOffset? Deadline, + TimeSpan? ExpectedDuration, + ImmutableDictionary Context, + Guid? ParentJobId, + Guid? CorrelationId, + int MaxRetries, + string? IdempotencyKey +); + +/// Priority levels with numeric weight for AI interpolation +public enum JobPriority +{ + Background = 0, + Low = 25, + Normal = 50, + High = 75, + Critical = 100 +} +``` + +### Job Context + +The `Context` dictionary carries domain-specific metadata that AI uses for intelligent scheduling: + +| Key | Purpose | Example | +|-----|---------|---------| +| `tenant_id` | Fair scheduling across tenants | `"tenant_123"` | +| `user_tier` | Premium users get priority boost | `"enterprise"` | +| `cost_center` | Resource accounting | `"billing"` | +| `data_locality` | Prefer workers near data | `"region:us-east"` | +| `resource_hint` | Expected resource usage | `"cpu:high,memory:2gb"` | +| `dependency_chain` | Critical path identification | `"checkout_flow"` | +| `sla_tier` | SLA classification | `"p99_100ms"` | + +### Effective Priority + +AI computes an **effective priority** at scheduling time, considering: + +``` +EffectivePriority = BasePriority + + DeadlineUrgency(deadline, now) + + TenantFairness(tenant, recentUsage) + + ResourceAvailability(hint, workerState) + + LearnedAdjustment(jobType, historicalPerformance) + + DependencyBoost(waitingDownstreamJobs) +``` + +--- + +## AI Scheduling Engine + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AI Scheduler Core │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ │ +│ │ Priority │ │ Workload │ │ Anomaly │ │ +│ │ Optimizer │ │ Predictor │ │ Detector │ │ +│ │ (DQN/PPO) │ │ (LSTM) │ │ (Isolation Forest)│ │ +│ └───────┬───────┘ └───────┬───────┘ └─────────┬─────────┘ │ +│ │ │ │ │ +│ └──────────────────┼────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Decision Engine │ │ +│ │ (Rule + ML) │ │ +│ └────────┬────────┘ │ +│ │ │ +├────────────────────────────┼────────────────────────────────────┤ +│ ▼ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Queue 1 │ │ Queue 2 │ │ Queue 3 │ │ Queue N │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ │ │ │ │ │ +│ └────────────┴────────────┴────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Worker 1 │ │ Worker 2 │ │ Worker 3 │ │ Worker N │ │ +│ │ cpu:4 │ │ cpu:8 │ │ gpu:1 │ │ cpu:2 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Priority Optimizer (Reinforcement Learning) + +Uses **Proximal Policy Optimization (PPO)** to learn optimal priority adjustments. + +**State Space:** +- Queue depths per priority level +- Worker utilization percentages +- Jobs approaching deadline (sliding window) +- Historical throughput (last 1h, 24h, 7d) +- Current time-of-day/day-of-week + +**Action Space:** +- Priority boost/reduction per job type (-20 to +20) +- Queue reordering recommendations +- Worker affinity assignments + +**Reward Function:** +``` +Reward = Σ(completed_jobs × priority_weight) + - Σ(deadline_misses × deadline_penalty) + - Σ(fairness_violation × fairness_penalty) + - resource_cost × cost_weight +``` + +**Training:** Continuous online learning with experience replay buffer stored in `scheduler_ai_experience` table. + +### Workload Predictor (Time Series) + +LSTM-based model predicting job arrival rates and processing times. + +**Inputs:** +- Historical arrival patterns (hourly/daily/weekly seasonality) +- Current pending job count +- External signals (deployment events, marketing campaigns) + +**Outputs:** +- Predicted arrivals next 1h/6h/24h +- Recommended worker scaling + +### Anomaly Detector + +Identifies scheduling anomalies that require human intervention: + +- Jobs stuck in processing (zombies) +- Sudden throughput drops +- Unusual failure rate spikes +- Resource exhaustion patterns + +--- + +## Database Schema (YAML Migration) + +```yaml +name: scheduler +tables: + # Core job storage + - name: scheduler_job + columns: + - name: Id + type: Text + - name: Queue + type: Text + - name: TypeName + type: Text + - name: SerializedArgs + type: Text + - name: BasePriority + type: Int + - name: EffectivePriority + type: Int + - name: State + type: Text + checkConstraint: State IN ('pending', 'scheduled', 'processing', 'completed', 'failed', 'dead') + - name: CreatedAt + type: Text + - name: ScheduledAt + type: Text + - name: StartedAt + type: Text + - name: CompletedAt + type: Text + - name: Deadline + type: Text + - name: ExpectedDurationMs + type: Int + - name: ActualDurationMs + type: Int + - name: WorkerId + type: Text + - name: RetryCount + type: Int + defaultValue: 0 + - name: MaxRetries + type: Int + defaultValue: 3 + - name: ParentJobId + type: Text + - name: CorrelationId + type: Text + - name: IdempotencyKey + type: Text + - name: ErrorMessage + type: Text + - name: ErrorStackTrace + type: Text + - name: ContextJson + type: Text + - name: ResultJson + type: Text + - name: Version + type: Int + defaultValue: 1 + primaryKey: + name: PK_scheduler_job + columns: + - Id + indexes: + - name: idx_job_queue_state_priority + columns: + - Queue + - State + - EffectivePriority + - name: idx_job_state_scheduled + columns: + - State + - ScheduledAt + - name: idx_job_correlation + columns: + - CorrelationId + - name: idx_job_idempotency + columns: + - IdempotencyKey + isUnique: true + - name: idx_job_deadline + columns: + - Deadline + - State + + # Worker registration and heartbeat + - name: scheduler_worker + columns: + - name: Id + type: Text + - name: Hostname + type: Text + - name: ProcessId + type: Int + - name: Queues + type: Text + - name: Concurrency + type: Int + - name: ResourceTags + type: Text + - name: State + type: Text + checkConstraint: State IN ('active', 'draining', 'stopped') + - name: LastHeartbeatAt + type: Text + - name: StartedAt + type: Text + - name: ProcessedCount + type: Int + defaultValue: 0 + - name: FailedCount + type: Int + defaultValue: 0 + - name: CurrentJobIds + type: Text + primaryKey: + name: PK_scheduler_worker + columns: + - Id + indexes: + - name: idx_worker_state_heartbeat + columns: + - State + - LastHeartbeatAt + + # Job context key-value pairs (denormalized for query performance) + - name: scheduler_job_context + columns: + - name: Id + type: Text + - name: JobId + type: Text + - name: Key + type: Text + - name: Value + type: Text + primaryKey: + name: PK_scheduler_job_context + columns: + - Id + foreignKeys: + - name: FK_job_context_job + columns: + - JobId + referencedTable: scheduler_job + referencedColumns: + - Id + onDelete: Cascade + indexes: + - name: idx_context_job + columns: + - JobId + - name: idx_context_key_value + columns: + - Key + - Value + + # Recurring job definitions + - name: scheduler_recurring + columns: + - name: Id + type: Text + - name: Name + type: Text + - name: CronExpression + type: Text + - name: TimeZone + type: Text + defaultValue: "'UTC'" + - name: Queue + type: Text + - name: TypeName + type: Text + - name: SerializedArgs + type: Text + - name: BasePriority + type: Int + - name: ContextJson + type: Text + - name: LastEnqueuedAt + type: Text + - name: NextEnqueueAt + type: Text + - name: Enabled + type: Int + defaultValue: 1 + primaryKey: + name: PK_scheduler_recurring + columns: + - Id + indexes: + - name: idx_recurring_next + columns: + - Enabled + - NextEnqueueAt + + # AI training experience buffer + - name: scheduler_ai_experience + columns: + - name: Id + type: Text + - name: Timestamp + type: Text + - name: StateJson + type: Text + - name: ActionJson + type: Text + - name: Reward + type: Double + - name: NextStateJson + type: Text + - name: Done + type: Int + primaryKey: + name: PK_scheduler_ai_experience + columns: + - Id + indexes: + - name: idx_experience_timestamp + columns: + - Timestamp + + # AI model snapshots + - name: scheduler_ai_model + columns: + - name: Id + type: Text + - name: ModelType + type: Text + checkConstraint: ModelType IN ('priority_optimizer', 'workload_predictor', 'anomaly_detector') + - name: Version + type: Int + - name: TrainedAt + type: Text + - name: MetricsJson + type: Text + - name: WeightsBlob + type: Blob + - name: IsActive + type: Int + defaultValue: 0 + primaryKey: + name: PK_scheduler_ai_model + columns: + - Id + indexes: + - name: idx_model_type_active + columns: + - ModelType + - IsActive + + # Scheduling metrics for AI training + - name: scheduler_metrics + columns: + - name: Id + type: Text + - name: Timestamp + type: Text + - name: MetricType + type: Text + - name: Queue + type: Text + - name: Value + type: Double + - name: DimensionsJson + type: Text + primaryKey: + name: PK_scheduler_metrics + columns: + - Id + indexes: + - name: idx_metrics_type_time + columns: + - MetricType + - Timestamp + - name: idx_metrics_queue_time + columns: + - Queue + - Timestamp + + # Dead letter queue with failure analysis + - name: scheduler_dead_letter + columns: + - name: Id + type: Text + - name: OriginalJobId + type: Text + - name: Queue + type: Text + - name: TypeName + type: Text + - name: SerializedArgs + type: Text + - name: ContextJson + type: Text + - name: FailedAt + type: Text + - name: FailureReason + type: Text + - name: RetryCount + type: Int + - name: FailureCategory + type: Text + checkConstraint: FailureCategory IN ('transient', 'permanent', 'timeout', 'resource', 'unknown') + - name: ReprocessedAt + type: Text + primaryKey: + name: PK_scheduler_dead_letter + columns: + - Id + indexes: + - name: idx_dead_letter_category + columns: + - FailureCategory + - FailedAt +``` + +--- + +## Record Types + +### Core Types + +```csharp +// GlobalUsings.cs +global using EnqueueResult = Outcome.Result; +global using EnqueueOk = Outcome.Result.Ok; +global using EnqueueError = Outcome.Result.Error; + +global using DequeueResult = Outcome.Result; +global using CompleteResult = Outcome.Result; +global using SchedulerVoidResult = Outcome.Result; +``` + +```csharp +namespace Scheduler; + +/// Void result placeholder for operations with no return value +public sealed record Unit +{ + public static readonly Unit Value = new(); + private Unit() { } +} + +/// Job state machine states +public enum JobState +{ + Pending, + Scheduled, + Processing, + Completed, + Failed, + Dead +} + +/// Failure categories for AI-driven retry decisions +public enum FailureCategory +{ + Transient, // Retry immediately with backoff + Permanent, // Move to dead letter + Timeout, // Retry with longer timeout + Resource, // Retry when resources available + Unknown // Needs manual investigation +} + +/// Base error type with closed hierarchy +public abstract record SchedulerError +{ + private protected SchedulerError() { } +} + +public sealed record SchedulerErrorNotFound(string EntityType, Guid Id) : SchedulerError; +public sealed record SchedulerErrorConcurrency(Guid JobId, int ExpectedVersion, int ActualVersion) : SchedulerError; +public sealed record SchedulerErrorTimeout(Guid JobId, TimeSpan Elapsed) : SchedulerError; +public sealed record SchedulerErrorSerialization(string TypeName, string Details) : SchedulerError; +public sealed record SchedulerErrorDatabase(string Operation, string Details) : SchedulerError; +public sealed record SchedulerErrorWorkerUnavailable(string Queue) : SchedulerError; +public sealed record SchedulerErrorIdempotencyViolation(string Key, Guid ExistingJobId) : SchedulerError; + +/// Immutable job definition +public sealed record Job( + Guid Id, + string Queue, + string TypeName, + string SerializedArgs, + JobPriority BasePriority, + int EffectivePriority, + JobState State, + DateTimeOffset CreatedAt, + DateTimeOffset? ScheduledAt, + DateTimeOffset? StartedAt, + DateTimeOffset? CompletedAt, + DateTimeOffset? Deadline, + TimeSpan? ExpectedDuration, + TimeSpan? ActualDuration, + Guid? WorkerId, + int RetryCount, + int MaxRetries, + Guid? ParentJobId, + Guid? CorrelationId, + string? IdempotencyKey, + string? ErrorMessage, + ImmutableDictionary Context, + int Version +); + +/// Job handed to worker for execution +public sealed record JobExecution( + Guid JobId, + string TypeName, + string SerializedArgs, + ImmutableDictionary Context, + DateTimeOffset? Deadline, + int AttemptNumber, + CancellationToken CancellationToken +); + +/// Result of job completion +public sealed record JobCompletion( + Guid JobId, + bool Success, + TimeSpan Duration, + string? ResultJson, + FailureCategory? FailureCategory, + string? ErrorMessage +); + +/// Worker registration +public sealed record Worker( + Guid Id, + string Hostname, + int ProcessId, + ImmutableArray Queues, + int Concurrency, + ImmutableDictionary ResourceTags, + WorkerState State, + DateTimeOffset LastHeartbeatAt, + DateTimeOffset StartedAt, + long ProcessedCount, + long FailedCount, + ImmutableArray CurrentJobIds +); + +public enum WorkerState +{ + Active, + Draining, + Stopped +} + +/// Recurring job schedule +public sealed record RecurringJob( + Guid Id, + string Name, + string CronExpression, + string TimeZone, + string Queue, + string TypeName, + string SerializedArgs, + JobPriority BasePriority, + ImmutableDictionary Context, + DateTimeOffset? LastEnqueuedAt, + DateTimeOffset NextEnqueueAt, + bool Enabled +); +``` + +### AI Types + +```csharp +namespace Scheduler.AI; + +/// Scheduler state snapshot for RL +public sealed record SchedulerState( + ImmutableDictionary Queues, + ImmutableArray Workers, + DateTimeOffset Timestamp, + DayOfWeek DayOfWeek, + int HourOfDay +); + +/// Queue state for AI observation +public sealed record QueueState( + string Name, + int PendingCount, + int ProcessingCount, + int DeadlineApproachingCount, + double AvgWaitTimeMs, + double P99WaitTimeMs, + ImmutableDictionary CountByPriority +); + +/// Worker snapshot for AI observation +public sealed record WorkerSnapshot( + Guid Id, + double CpuUtilization, + double MemoryUtilization, + int ActiveJobCount, + int Concurrency, + ImmutableDictionary ResourceTags +); + +/// AI scheduling decision +public sealed record SchedulingDecision( + Guid JobId, + int PriorityAdjustment, + Guid? PreferredWorkerId, + string Reasoning +); + +/// Experience tuple for RL training +public sealed record Experience( + Guid Id, + DateTimeOffset Timestamp, + SchedulerState State, + SchedulingAction Action, + double Reward, + SchedulerState? NextState, + bool Done +); + +/// Action taken by AI scheduler +public sealed record SchedulingAction( + ImmutableArray Decisions, + ImmutableDictionary QueuePriorityBoosts +); + +/// AI model metadata +public sealed record AIModel( + Guid Id, + AIModelType Type, + int Version, + DateTimeOffset TrainedAt, + ImmutableDictionary Metrics, + byte[] Weights, + bool IsActive +); + +public enum AIModelType +{ + PriorityOptimizer, + WorkloadPredictor, + AnomalyDetector +} +``` + +--- + +## API Surface + +### Enqueue Operations + +```csharp +public static class Scheduler +{ + /// Enqueue a job for immediate processing + public static EnqueueResult Enqueue( + Func getConnection, + TJob job, + string queue = "default", + JobPriority priority = JobPriority.Normal, + ImmutableDictionary? context = null, + string? idempotencyKey = null, + ILogger? logger = null + ) where TJob : notnull; + + /// Enqueue a job for future processing + public static EnqueueResult Schedule( + Func getConnection, + TJob job, + DateTimeOffset scheduledAt, + string queue = "default", + JobPriority priority = JobPriority.Normal, + DateTimeOffset? deadline = null, + ImmutableDictionary? context = null, + ILogger? logger = null + ) where TJob : notnull; + + /// Create or update a recurring job + public static SchedulerVoidResult AddOrUpdateRecurring( + Func getConnection, + string recurringJobId, + TJob job, + string cronExpression, + string queue = "default", + JobPriority priority = JobPriority.Normal, + string timeZone = "UTC", + ImmutableDictionary? context = null, + ILogger? logger = null + ) where TJob : notnull; + + /// Enqueue a continuation job + public static EnqueueResult ContinueWith( + Func getConnection, + Guid parentJobId, + TJob job, + string queue = "default", + JobPriority priority = JobPriority.Normal, + ILogger? logger = null + ) where TJob : notnull; +} +``` + +### Worker Operations + +```csharp +public static class SchedulerWorker +{ + /// Register a worker and begin processing + public static async Task RunAsync( + Func getConnection, + ImmutableArray queues, + int concurrency, + Func> processJob, + ImmutableDictionary? resourceTags = null, + CancellationToken cancellationToken = default, + ILogger? logger = null + ); + + /// Signal worker to drain and stop + public static SchedulerVoidResult RequestDrain( + Func getConnection, + Guid workerId, + ILogger? logger = null + ); +} +``` + +### AI Operations + +```csharp +public static class SchedulerAI +{ + /// Run AI priority optimization pass + public static async Task OptimizePrioritiesAsync( + Func getConnection, + AIModel model, + ILogger? logger = null + ); + + /// Get workload prediction for capacity planning + public static async Task> PredictWorkloadAsync( + Func getConnection, + AIModel model, + TimeSpan horizon, + ILogger? logger = null + ); + + /// Train model on recent experiences + public static async Task> TrainModelAsync( + Func getConnection, + AIModelType modelType, + TrainingConfig config, + ILogger? logger = null + ); + + /// Detect scheduling anomalies + public static async Task, SchedulerError>> DetectAnomaliesAsync( + Func getConnection, + AIModel model, + ILogger? logger = null + ); +} + +public sealed record WorkloadPrediction( + ImmutableDictionary> ByQueue, + int RecommendedWorkerCount, + double ConfidenceScore +); + +public sealed record PredictedLoad( + DateTimeOffset Timestamp, + double ExpectedArrivalRate, + double LowerBound, + double UpperBound +); + +public sealed record Anomaly( + AnomalyType Type, + string Description, + double Severity, + ImmutableDictionary Context +); + +public enum AnomalyType +{ + ZombieJob, + ThroughputDrop, + FailureSpike, + ResourceExhaustion, + DeadlineMissPattern +} + +public sealed record TrainingConfig( + int BatchSize, + int Epochs, + double LearningRate, + int ExperienceBufferSize +); +``` + +--- + +## Integration Pattern + +### Minimal Setup + +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); + +// Register connection factory (no singletons) +builder.Services.AddSingleton>(() => +{ + var conn = new SqliteConnection(builder.Configuration.GetConnectionString("Scheduler")); + conn.Open(); + return conn; +}); + +// Register background worker +builder.Services.AddHostedService(); + +var app = builder.Build(); + +// Enqueue jobs from API endpoints +app.MapPost("/orders", async (CreateOrderRequest request, Func getConn) => +{ + var result = Scheduler.Enqueue( + getConnection: getConn, + job: new ProcessOrderJob(request.OrderId), + queue: "orders", + priority: JobPriority.High, + context: ImmutableDictionary.Empty + .Add("tenant_id", request.TenantId) + .Add("user_tier", request.UserTier) + ); + + return result switch + { + EnqueueOk ok => Results.Accepted($"/jobs/{ok.Value}"), + EnqueueError err => Results.Problem(err.Value.ToString()) + }; +}); +``` + +### Worker Service + +```csharp +internal sealed class SchedulerWorkerService : BackgroundService +{ + private readonly ILogger _logger; + private readonly Func _getConnection; + private readonly IServiceProvider _services; + + public SchedulerWorkerService( + ILogger logger, + Func getConnection, + IServiceProvider services + ) + { + _logger = logger; + _getConnection = getConnection; + _services = services; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await SchedulerWorker.RunAsync( + getConnection: _getConnection, + queues: ["orders", "notifications", "reports"], + concurrency: Environment.ProcessorCount, + processJob: ProcessJobAsync, + resourceTags: ImmutableDictionary.Empty + .Add("region", "us-east-1") + .Add("instance_type", "c5.xlarge"), + cancellationToken: stoppingToken, + logger: _logger + ).ConfigureAwait(false); + } + + private async Task ProcessJobAsync(JobExecution execution) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Resolve and execute job handler + var jobType = Type.GetType(execution.TypeName); + var handler = _services.GetRequiredService(typeof(IJobHandler<>).MakeGenericType(jobType!)); + + await ((dynamic)handler).HandleAsync( + JsonSerializer.Deserialize(execution.SerializedArgs, jobType), + execution.Context, + execution.CancellationToken + ).ConfigureAwait(false); + + return new JobCompletion( + JobId: execution.JobId, + Success: true, + Duration: stopwatch.Elapsed, + ResultJson: null, + FailureCategory: null, + ErrorMessage: null + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Job {JobId} failed: {Message}", execution.JobId, ex.Message); + + return new JobCompletion( + JobId: execution.JobId, + Success: false, + Duration: stopwatch.Elapsed, + ResultJson: null, + FailureCategory: ClassifyFailure(ex), + ErrorMessage: ex.Message + ); + } + } + + private static FailureCategory ClassifyFailure(Exception ex) => ex switch + { + TimeoutException => FailureCategory.Timeout, + HttpRequestException { StatusCode: >= (System.Net.HttpStatusCode)500 } => FailureCategory.Transient, + HttpRequestException { StatusCode: >= (System.Net.HttpStatusCode)400 } => FailureCategory.Permanent, + OutOfMemoryException => FailureCategory.Resource, + InvalidOperationException => FailureCategory.Permanent, + _ => FailureCategory.Unknown + }; +} +``` + +--- + +## AI Training Loop + +```csharp +internal sealed class AITrainingWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly Func _getConnection; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + // Collect experiences from recent scheduling decisions + var state = await CaptureSchedulerStateAsync().ConfigureAwait(false); + + // Run priority optimization with current model + var modelResult = await GetActiveModelAsync(AIModelType.PriorityOptimizer).ConfigureAwait(false); + + if (modelResult is Outcome.Result.Ok ok) + { + await SchedulerAI.OptimizePrioritiesAsync( + getConnection: _getConnection, + model: ok.Value, + logger: _logger + ).ConfigureAwait(false); + } + + // Periodically retrain models + if (ShouldRetrain()) + { + _logger.LogInformation("Starting AI model retraining"); + + await SchedulerAI.TrainModelAsync( + getConnection: _getConnection, + modelType: AIModelType.PriorityOptimizer, + config: new TrainingConfig( + BatchSize: 64, + Epochs: 10, + LearningRate: 0.001, + ExperienceBufferSize: 10000 + ), + logger: _logger + ).ConfigureAwait(false); + } + + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); + } + } +} +``` + +--- + +## Observability + +### Metrics Emitted + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `scheduler_jobs_enqueued_total` | Counter | queue, priority | Jobs enqueued | +| `scheduler_jobs_completed_total` | Counter | queue, status | Jobs completed | +| `scheduler_jobs_duration_seconds` | Histogram | queue, job_type | Processing time | +| `scheduler_queue_depth` | Gauge | queue, priority | Pending jobs | +| `scheduler_deadline_misses_total` | Counter | queue | SLA violations | +| `scheduler_ai_priority_adjustments` | Histogram | queue | AI adjustments | +| `scheduler_worker_utilization` | Gauge | worker_id | Worker busyness | + +### Structured Logging + +```csharp +_logger.LogInformation( + "Job {JobId} scheduled with effective priority {EffectivePriority} " + + "(base: {BasePriority}, AI adjustment: {Adjustment})", + job.Id, + effectivePriority, + job.BasePriority, + aiAdjustment +); +``` + +--- + +## Future Enhancements + +1. **Federated Learning** – Train models across microservices without centralizing data +2. **Explainable AI** – Human-readable explanations for scheduling decisions +3. **Predictive SLA Alerts** – Warn before deadlines are missed +4. **Cost-Aware Scheduling** – Optimize for cloud compute costs +5. **A/B Testing** – Compare scheduling strategies in production + +--- + +## References + +- [Decima: Learning to Schedule](https://web.mit.edu/decima/) - MIT/Microsoft graph neural network scheduler +- [DeepRM: Resource Management with Deep RL](https://people.csail.mit.edu/alizadeh/papers/deeprm-hotnets16.pdf) - MIT CSAIL +- [Autopilot: Google's Workload Autoscaling](https://research.google/pubs/pub49174/) - Google Research +- [Proximal Policy Optimization (PPO)](https://arxiv.org/abs/1707.06347) - OpenAI +- [Isolation Forest for Anomaly Detection](https://cs.nju.edu.cn/zhouzh/zhouzh.files/publication/icdm08b.pdf) - Zhou et al. +- [Performance Modeling and Design of Computer Systems](https://www.cs.cmu.edu/~harchol/PerformanceModeling/book.html) - CMU +- [Real-Time Systems (Liu & Layland)](https://dl.acm.org/doi/10.1145/321738.321743) - Foundational EDF paper +- [Hangfire Documentation](https://docs.hangfire.io/) - Inspiration for API design diff --git a/Lql/LqlExtension/eslint.config.mjs b/Lql/LqlExtension/eslint.config.mjs index e802d768..916594b8 100644 --- a/Lql/LqlExtension/eslint.config.mjs +++ b/Lql/LqlExtension/eslint.config.mjs @@ -1,4 +1,4 @@ -// agent-pmo:d75d5c8 +// agent-pmo:795a9c2 import tseslint from "typescript-eslint"; export default tseslint.config( diff --git a/Lql/LqlExtension/package-lock.json b/Lql/LqlExtension/package-lock.json index 9cb20314..e4d4bea1 100644 --- a/Lql/LqlExtension/package-lock.json +++ b/Lql/LqlExtension/package-lock.json @@ -1,12 +1,12 @@ { "name": "lql-language-support", - "version": "0.1.0", + "version": "0.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lql-language-support", - "version": "0.1.0", + "version": "0.0.0-dev", "dependencies": { "ts-node": "^10.9.2", "vscode-languageclient": "^8.0.2" @@ -16,7 +16,7 @@ "@types/mocha": "^10.0.0", "@types/node": "16.x", "@types/vscode": "^1.74.0", - "@vscode/test-electron": "^2.2.0", + "@vscode/test-electron": "3.1.0", "eslint": "^9.0.0", "glob": "^8.0.3", "mocha": "^10.2.0", @@ -469,29 +469,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1000,9 +1014,9 @@ } }, "node_modules/@vscode/test-electron": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", - "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.1.0.tgz", + "integrity": "sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==", "dev": true, "license": "MIT", "dependencies": { @@ -1013,7 +1027,7 @@ "semver": "^7.6.2" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/acorn": { @@ -1213,9 +1227,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", - "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1304,9 +1318,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -1324,11 +1338,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -1443,9 +1457,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -1926,9 +1940,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "dev": true, "license": "ISC" }, @@ -3688,11 +3702,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -5755,9 +5772,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.12.0.tgz", - "integrity": "sha512-GrKEsc3ughskmGA9jevVlIOPMiiAHJ4OFUtaAH+NhfTUSiZ1wMPIQqQvAJUrJspFXJt3EBWgpAeoHEDVT1IBug==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -5765,9 +5782,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/Lql/LqlExtension/package.json b/Lql/LqlExtension/package.json index 3ff5cbfe..be4a294a 100644 --- a/Lql/LqlExtension/package.json +++ b/Lql/LqlExtension/package.json @@ -2,7 +2,7 @@ "name": "lql-language-support", "displayName": "Lambda Query Language (LQL)", "description": "Language support for Lambda Query Language with syntax highlighting, IntelliSense, and error checking", - "version": "0.1.0", + "version": "0.0.0-dev", "publisher": "lql-team", "engines": { "vscode": "^1.74.0" @@ -197,7 +197,7 @@ "@types/mocha": "^10.0.0", "@types/node": "16.x", "@types/vscode": "^1.74.0", - "@vscode/test-electron": "^2.2.0", + "@vscode/test-electron": "3.1.0", "eslint": "^9.0.0", "glob": "^8.0.3", "mocha": "^10.2.0", diff --git a/Lql/LqlExtension/shipwright.json b/Lql/LqlExtension/shipwright.json new file mode 100644 index 00000000..d82b7f03 --- /dev/null +++ b/Lql/LqlExtension/shipwright.json @@ -0,0 +1,20 @@ +{ + "manifestVersion": 1, + "product": { + "id": "lql-language-support", + "displayName": "Lambda Query Language (LQL)", + "version": "0.0.0-dev" + }, + "components": [ + { + "id": "lql-lsp", + "kind": "lsp", + "language": "rust", + "binaryName": "lql-lsp", + "expectedVersion": "0.0.0-dev", + "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"], + "sources": ["user-setting", "bundled"], + "required": true + } + ] +} diff --git a/Lql/LqlExtension/src/extension.ts b/Lql/LqlExtension/src/extension.ts index 4c4b7943..7cb16b55 100644 --- a/Lql/LqlExtension/src/extension.ts +++ b/Lql/LqlExtension/src/extension.ts @@ -4,12 +4,7 @@ import * as https from "https"; import * as http from "http"; import { spawnSync } from "child_process"; import * as vscode from "vscode"; -import { - LanguageClient, - LanguageClientOptions, - ServerOptions, - TransportKind, -} from "vscode-languageclient/node"; +import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from "vscode-languageclient/node"; let client: LanguageClient | undefined; let outputChannel: vscode.OutputChannel; @@ -20,24 +15,16 @@ function log(msg: string): void { outputChannel.appendLine(`[LQL] ${msg}`); } +/** Map platform/arch to the vsce target name used for bundled binaries. */ +function getVsceTarget(): string | undefined { + const target = `${process.platform}-${process.arch}`; + return ["linux-x64", "darwin-x64", "darwin-arm64", "win32-x64"].includes(target) ? target : undefined; +} + /** Map platform/arch to the release asset name. */ function getLspAssetName(): string | undefined { - const platform = process.platform; - const arch = process.arch; - - if (platform === "linux" && arch === "x64") { - return "lql-lsp-linux-x64"; - } - if (platform === "darwin" && arch === "x64") { - return "lql-lsp-darwin-x64"; - } - if (platform === "darwin" && arch === "arm64") { - return "lql-lsp-darwin-arm64"; - } - if (platform === "win32" && arch === "x64") { - return "lql-lsp-windows-x64.exe"; - } - return undefined; + const target = getVsceTarget(); + return target === undefined ? undefined : target === "win32-x64" ? "lql-lsp-windows-x64.exe" : `lql-lsp-${target}`; } /** Get the extension version from package.json to find the matching GH release. */ @@ -89,49 +76,36 @@ function getBinaryVersion(binary: string): string | undefined { return undefined; } const output = `${result.stdout}\n${result.stderr}`; - const match = /(\d+\.\d+\.\d+)/.exec(output); - return match === null ? undefined : match[1]; + const versionLine = output + .split("\n") + .map((line) => line.trim()) + .find((line) => line.startsWith("lql-lsp ")); + return versionLine?.slice("lql-lsp ".length); } catch { return undefined; } } /** - * Look for `lql-lsp` on the system PATH and return its location if its - * --version matches the extension version. Used so dev / test / CI can - * install the binary into PATH (e.g. via cargo install or copying the - * cargo build output) and have the extension use it without downloading. + * Look for the target-specific `lql-lsp` bundled in the installed VSIX at + * bin//lql-lsp[.exe]. Returns undefined in dev / test / CI + * source-checkout runs where no packaged binary was staged. */ -function findOnPathMatchingVersion(expectedVersion: string): string | undefined { - const binaryName = process.platform === "win32" ? "lql-lsp.exe" : "lql-lsp"; - const pathEnv = process.env.PATH ?? ""; - const sep = process.platform === "win32" ? ";" : ":"; - for (const dir of pathEnv.split(sep)) { - if (dir === "") { - continue; - } - const candidate = path.join(dir, binaryName); - if (!fs.existsSync(candidate)) { - continue; - } - const version = getBinaryVersion(candidate); - if (version === expectedVersion) { - return candidate; - } +function findPackagedBinary(context: vscode.ExtensionContext): string | undefined { + const target = getVsceTarget(); + if (target === undefined) { + return undefined; } - return undefined; + const binaryName = process.platform === "win32" ? "lql-lsp.exe" : "lql-lsp"; + const candidate = path.join(context.extensionPath, "bin", target, binaryName); + return fs.existsSync(candidate) ? candidate : undefined; } /** * Look for a locally-built `lql-lsp` in the Rust cargo target folder - * adjacent to the extension install path. Used by dev / test / CI runs - * where the binary is built but not installed onto PATH (or where the - * test harness strips PATH from the spawned VS Code process). + * adjacent to the extension install path. Used by dev / test / CI runs. */ -function findLocalCargoBuild( - context: vscode.ExtensionContext, - expectedVersion: string, -): string | undefined { +function findLocalCargoBuild(context: vscode.ExtensionContext, expectedVersion: string): string | undefined { const binaryName = process.platform === "win32" ? "lql-lsp.exe" : "lql-lsp"; const extPath = context.extensionPath; const candidates = [ @@ -152,17 +126,14 @@ function findLocalCargoBuild( } /** Download the LSP binary from the GitHub release matching the extension version. */ -async function downloadLspBinary( - context: vscode.ExtensionContext, -): Promise { +async function downloadLspBinary(context: vscode.ExtensionContext): Promise { const assetName = getLspAssetName(); if (assetName === undefined) { throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`); } const binDir = path.join(context.globalStorageUri.fsPath, "bin"); - const binaryName = - process.platform === "win32" ? "lql-lsp.exe" : "lql-lsp"; + const binaryName = process.platform === "win32" ? "lql-lsp.exe" : "lql-lsp"; const binaryPath = path.join(binDir, binaryName); if (fs.existsSync(binaryPath)) { @@ -186,15 +157,13 @@ async function downloadLspBinary( if (process.platform !== "win32") { fs.chmodSync(binaryPath, 0o755); } - }, + } ); return binaryPath; } -export async function activate( - context: vscode.ExtensionContext, -): Promise { +export async function activate(context: vscode.ExtensionContext): Promise { outputChannel = vscode.window.createOutputChannel("LQL Language Server"); log("Extension activating..."); log(`Platform: ${process.platform} ${process.arch}`); @@ -216,38 +185,33 @@ export async function activate( if (customPath !== "") { if (!fs.existsSync(customPath)) { log(`ERROR: Custom LSP path does not exist: ${customPath}`); - vscode.window.showErrorMessage( - `LQL: Custom language server path not found: ${customPath}`, - ); + vscode.window.showErrorMessage(`LQL: Custom language server path not found: ${customPath}`); return; } serverBinary = customPath; log(`LSP binary (custom): ${serverBinary}`); } else { - // 1. Prefer a binary on PATH whose --version matches the extension. - const onPath = findOnPathMatchingVersion(expectedVersion); - if (onPath !== undefined) { - serverBinary = onPath; - log(`LSP binary (PATH, version ${expectedVersion}): ${serverBinary}`); + // 1. Prefer the target-specific binary bundled in the installed VSIX + // (production layout: bin//lql-lsp[.exe]). + const bundled = findPackagedBinary(context); + if (bundled !== undefined) { + serverBinary = bundled; + log(`LSP binary (bundled for ${getVsceTarget() ?? "unknown"}): ${serverBinary}`); } else { - // 2. Fall back to a locally-built cargo target adjacent to the - // extension (used by dev / test / CI runs where the binary isn't - // on PATH inside the spawned VS Code process). + // 2. Use a locally built binary in source checkouts. const localBuild = findLocalCargoBuild(context, expectedVersion); if (localBuild !== undefined) { serverBinary = localBuild; log(`LSP binary (local cargo build, version ${expectedVersion}): ${serverBinary}`); } else { - // 3. Otherwise download the matching release into globalStorage. + // 3. Download the matching release into globalStorage. try { serverBinary = await downloadLspBinary(context); log(`LSP binary (downloaded): ${serverBinary}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`ERROR: ${message}`); - vscode.window.showErrorMessage( - `LQL: Failed to download language server: ${message}`, - ); + vscode.window.showErrorMessage(`LQL: Failed to download language server: ${message}`); return; } } @@ -274,7 +238,7 @@ export async function activate( const initOptions: Record = {}; if (connectionString !== "") { initOptions.connectionString = connectionString; - log(`Database: ${connectionString}`); + log(`Database connection string: configured`); } if (aiProvider !== "") { initOptions.aiProvider = { @@ -298,12 +262,7 @@ export async function activate( initializationOptions: initOptions, }; - client = new LanguageClient( - "lql-language-server", - "LQL Language Server", - serverOptions, - clientOptions, - ); + client = new LanguageClient("lql-language-server", "LQL Language Server", serverOptions, clientOptions); context.subscriptions.push( vscode.commands.registerCommand("lql.formatDocument", async () => { @@ -311,7 +270,7 @@ export async function activate( if (editor?.document.languageId === "lql") { await vscode.commands.executeCommand("editor.action.formatDocument"); } - }), + }) ); context.subscriptions.push( @@ -319,18 +278,18 @@ export async function activate( const editor = vscode.window.activeTextEditor; if (editor?.document.languageId === "lql") { void vscode.window.showInformationMessage( - "LQL validation triggered — check the Problems panel for diagnostics.", + "LQL validation triggered — check the Problems panel for diagnostics." ); } - }), + }) ); context.subscriptions.push( vscode.commands.registerCommand("lql.showCompiledSql", () => { void vscode.window.showInformationMessage( - "SQL compilation requires the LQL runtime. Use the CLI or Browser app.", + "SQL compilation requires the LQL runtime. Use the CLI or Browser app." ); - }), + }) ); log("Starting LSP client..."); diff --git a/Lql/LqlExtension/src/test/runTest.ts b/Lql/LqlExtension/src/test/runTest.ts index 161eab1d..698df4aa 100644 --- a/Lql/LqlExtension/src/test/runTest.ts +++ b/Lql/LqlExtension/src/test/runTest.ts @@ -1,3 +1,5 @@ +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import { runTests } from "@vscode/test-electron"; @@ -5,11 +7,15 @@ async function main() { try { const extensionDevelopmentPath = path.resolve(__dirname, "../../"); const extensionTestsPath = path.resolve(__dirname, "./suite/index"); + // The default user-data-dir lives under the repo; on deep checkouts its + // IPC socket path exceeds the macOS 103-char AF_UNIX limit (EINVAL). + const userDataDir = path.join(os.tmpdir(), "lql-ext-test"); + fs.mkdirSync(userDataDir, { recursive: true }); await runTests({ extensionDevelopmentPath, extensionTestsPath, - launchArgs: ["--disable-extensions"], + launchArgs: ["--disable-extensions", `--user-data-dir=${userDataDir}`], }); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); diff --git a/Lql/LqlWebsite-Eleventy/_site/index.html b/Lql/LqlWebsite-Eleventy/_site/index.html index 6d67b022..0b10bfb6 100644 --- a/Lql/LqlWebsite-Eleventy/_site/index.html +++ b/Lql/LqlWebsite-Eleventy/_site/index.html @@ -20,7 +20,7 @@ - + @@ -38,43 +38,49 @@ @@ -82,7 +88,7 @@
- +
@@ -434,31 +440,35 @@

Run The Transpiler

diff --git a/Lql/LqlWebsite-Eleventy/_site/playground/index.html b/Lql/LqlWebsite-Eleventy/_site/playground/index.html deleted file mode 100644 index 90b51b4b..00000000 --- a/Lql/LqlWebsite-Eleventy/_site/playground/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - LQL Transpiler Playground - - - - - - - -
Loading LQL transpiler...
- -
- The LQL transpiler failed to load. - Reload - 🗙 -
- - - - - diff --git a/Lql/LqlWebsite-Eleventy/eleventy.config.js b/Lql/LqlWebsite-Eleventy/eleventy.config.js index 4ef89eb1..76933214 100644 --- a/Lql/LqlWebsite-Eleventy/eleventy.config.js +++ b/Lql/LqlWebsite-Eleventy/eleventy.config.js @@ -20,6 +20,11 @@ export default function(eleventyConfig) { const md = markdownIt(mdOptions).use(markdownItAnchor, mdAnchorOptions); eleventyConfig.setLibrary("md", md); + // Release notes carry untrusted PR titles/handles — render with raw HTML OFF. + // Implements [WEB-RELEASES]. + const releaseMd = markdownIt({ html: false, linkify: true, breaks: false }); + eleventyConfig.addFilter("releaseNotes", (body) => (body ? releaseMd.render(body) : "")); + eleventyConfig.addPlugin(syntaxHighlight); eleventyConfig.addPlugin(pluginRss); eleventyConfig.addPlugin(eleventyNavigationPlugin); diff --git a/Lql/LqlWebsite-Eleventy/package-lock.json b/Lql/LqlWebsite-Eleventy/package-lock.json index 49890f32..a73a1fbf 100644 --- a/Lql/LqlWebsite-Eleventy/package-lock.json +++ b/Lql/LqlWebsite-Eleventy/package-lock.json @@ -8,10 +8,11 @@ "name": "lql-website", "version": "1.0.0", "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" } }, @@ -39,45 +40,45 @@ } }, "node_modules/@11ty/eleventy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.2.tgz", - "integrity": "sha512-IcsDlbXnBf8cHzbM1YBv3JcTyLB35EK88QexmVyFdVJVgUU6bh9g687rpxryJirHzo06PuwnYaEEdVZQfIgRGg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.6.tgz", + "integrity": "sha512-ZlSiR1PLdS2lv7TelBgWAhcvMiLNZkPBlLEb+lh7kGYZ+Mk0bo9qcYgVsewvw9W7Em0RH3wd01h5fAstNDh0zA==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/dependency-tree": "^4.0.0", - "@11ty/dependency-tree-esm": "^2.0.0", + "@11ty/dependency-tree": "^4.0.2", + "@11ty/dependency-tree-esm": "^2.0.4", "@11ty/eleventy-dev-server": "^2.0.8", - "@11ty/eleventy-plugin-bundle": "^3.0.6", + "@11ty/eleventy-plugin-bundle": "^3.0.7", "@11ty/eleventy-utils": "^2.0.7", "@11ty/lodash-custom": "^4.17.21", - "@11ty/posthtml-urls": "^1.0.1", - "@11ty/recursive-copy": "^4.0.2", + "@11ty/posthtml-urls": "^1.0.3", + "@11ty/recursive-copy": "^4.0.4", "@sindresorhus/slugify": "^2.2.1", "bcp-47-normalize": "^2.3.0", "chokidar": "^3.6.0", - "debug": "^4.4.1", + "debug": "^4.4.3", "dependency-graph": "^1.0.0", "entities": "^6.0.1", "filesize": "^10.1.6", "gray-matter": "^4.0.3", "iso-639-1": "^3.1.5", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "kleur": "^4.1.5", - "liquidjs": "^10.21.1", - "luxon": "^3.6.1", - "markdown-it": "^14.1.0", + "liquidjs": "^10.27.0", + "luxon": "^3.7.2", + "markdown-it": "^14.2.0", "minimist": "^1.2.8", - "moo": "^0.5.2", + "moo": "0.5.2", "node-retrieve-globals": "^6.0.1", "nunjucks": "^3.2.4", - "picomatch": "^4.0.2", + "picomatch": "^4.0.4", "please-upgrade-node": "^3.2.0", - "posthtml": "^0.16.6", + "posthtml": "^0.16.7", "posthtml-match-helper": "^2.0.3", - "semver": "^7.7.2", - "slugify": "^1.6.6", - "tinyglobby": "^0.2.14" + "semver": "^7.8.1", + "slugify": "^1.6.9", + "tinyglobby": "^0.2.16" }, "bin": { "eleventy": "cmd.cjs" @@ -122,29 +123,19 @@ } }, "node_modules/@11ty/eleventy-navigation": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-0.3.5.tgz", - "integrity": "sha512-4aKW5aIQDFed8xs1G1pWcEiFPcDSwZtA4IH1eERtoJ+Xy+/fsoe0pzbDmw84bHZ9ACny5jblENhfZhcCxklqQw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-1.0.5.tgz", + "integrity": "sha512-zb6xe29cM9viSdYtZywKIkJw2HIROyBINdBcFWC9uD0c/jYOTAex5nwy3HNEuh5t6/Ld/S9V4gEizfmeYuYpCQ==", "dev": true, "license": "MIT", "dependencies": { - "dependency-graph": "^0.11.0" + "dependency-graph": "^1.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/11ty" } }, - "node_modules/@11ty/eleventy-navigation/node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/@11ty/eleventy-plugin-bundle": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.7.tgz", @@ -165,16 +156,16 @@ } }, "node_modules/@11ty/eleventy-plugin-rss": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-2.0.4.tgz", - "integrity": "sha512-LF60sGVlxGTryQe3hTifuzrwF8R7XbrNsM2xfcDcNMSliLN4kmB+7zvoLRySRx0AQDjqhPTAeeeT0ra6/9zHUQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-3.0.0.tgz", + "integrity": "sha512-kKW4DcR57xAyRx0e8gNhKh56ahHVEaAj8/TuXQDnw+B46ig2bWADJAlyj/GdV37IG5ja9dZ4SgKZrs/CHz6YWQ==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/eleventy-utils": "^2.0.0", - "@11ty/posthtml-urls": "^1.0.1", - "debug": "^4.4.0", - "posthtml": "^0.16.6" + "@11ty/eleventy-utils": "^2.0.7", + "@11ty/posthtml-urls": "^1.0.2", + "debug": "^4.4.3", + "posthtml": "^0.16.7" }, "funding": { "type": "opencollective", @@ -224,9 +215,9 @@ } }, "node_modules/@11ty/posthtml-urls": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.2.tgz", - "integrity": "sha512-0vaV3Wt0surZ+oS1VdKKe0axeeupuM+l7W/Z866WFQwF+dGg2Tc/nmhk/5l74/Y55P8KyImnLN9CdygNw2huHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.3.tgz", + "integrity": "sha512-1YvhnkaNlFnnJic1rBMWmTC2adbuy+JQiBfl1Hecr1Wjjik1pQZmGyk/eC9zKX/FQv52s2Nht1Gi/UwhYqrBeg==", "dev": true, "license": "MIT", "dependencies": { @@ -240,15 +231,15 @@ } }, "node_modules/@11ty/recursive-copy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.3.tgz", - "integrity": "sha512-SX48BTLEGX8T/OsKWORsHAAeiDsbFl79Oa/0Wg/mv/d27b7trCVZs7fMHvpSgDvZz/fZqx5rDk8+nx5oyT7xBw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.4.tgz", + "integrity": "sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==", "dev": true, "license": "ISC", "dependencies": { "errno": "^1.0.0", "junk": "^3.1.0", - "maximatch": "^0.1.0", + "minimatch": "^3.1.5", "slash": "^3.0.0" }, "engines": { @@ -383,49 +374,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-differ": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", - "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -496,9 +444,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -930,9 +878,9 @@ } }, "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -1125,10 +1073,20 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1168,19 +1126,29 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } }, "node_modules/liquidjs": { - "version": "10.25.0", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.0.tgz", - "integrity": "sha512-XpO7AiGULTG4xcTlwkcTI5JreFG7b6esLCLp+aUSh7YuQErJZEoUXre9u9rbdb0057pfWG4l0VursvLd5Q/eAw==", + "version": "10.27.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.0.tgz", + "integrity": "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==", "dev": true, "license": "MIT", "dependencies": { @@ -1216,15 +1184,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -1257,22 +1235,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/maximatch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", - "integrity": "sha512-9ORVtDUFk4u/NFfo0vG/ND/z7UQCVZBL539YW0+U1I7H1BkZwizcPx5foFv7LCPcBnm2U6RjFnQOsIvN4/Vm2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-differ": "^1.0.0", - "array-union": "^1.0.1", - "arrify": "^1.0.0", - "minimatch": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -1354,9 +1316,9 @@ } }, "node_modules/moo": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", - "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true, "license": "BSD-3-Clause" }, @@ -1463,9 +1425,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1616,9 +1578,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -1680,9 +1642,9 @@ } }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "dev": true, "license": "MIT", "engines": { @@ -1730,14 +1692,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/Lql/LqlWebsite-Eleventy/package.json b/Lql/LqlWebsite-Eleventy/package.json index 2f7b55d0..99c3d9fe 100644 --- a/Lql/LqlWebsite-Eleventy/package.json +++ b/Lql/LqlWebsite-Eleventy/package.json @@ -8,10 +8,11 @@ "build": "rm -rf _site && eleventy" }, "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" } } diff --git a/Lql/LqlWebsite-Eleventy/src/_data/navigation.json b/Lql/LqlWebsite-Eleventy/src/_data/navigation.json index a860d3d1..a8ace9f7 100644 --- a/Lql/LqlWebsite-Eleventy/src/_data/navigation.json +++ b/Lql/LqlWebsite-Eleventy/src/_data/navigation.json @@ -5,6 +5,7 @@ { "text": "Migrations", "url": "/#migrations" }, { "text": "Tooling", "url": "/#tooling" }, { "text": "Playground", "url": "/playground/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "GitHub", "url": "https://github.com/Nimblesite/DataProvider", "external": true } ], "footer": [ @@ -12,6 +13,7 @@ { "text": "Examples", "url": "/#examples" }, { "text": "Migrations", "url": "/#migrations" }, { "text": "Playground", "url": "/playground/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "DataProvider", "url": "https://dataprovider.dev", "external": true }, { "text": "GitHub", "url": "https://github.com/Nimblesite/DataProvider", "external": true } ] diff --git a/Lql/LqlWebsite-Eleventy/src/_data/release.js b/Lql/LqlWebsite-Eleventy/src/_data/release.js new file mode 100644 index 00000000..d4eb4bc1 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/src/_data/release.js @@ -0,0 +1,102 @@ +// agent-pmo:795a9c2 +// Build-time GitHub Releases data file. Implements [WEB-RELEASES]. +// +// Eleventy runs this during the static build and bakes the result into the +// /releases/ page — the browser makes ZERO network calls. Notes are rendered +// by the `releaseNotes` filter in eleventy.config.js (markdown-it, html:false), +// because release bodies carry untrusted PR titles/handles. + +const REPO = "Nimblesite/DataProvider"; +const MAX_RELEASES = 30; +const RECENT_COUNT = 4; +const API_URL = `https://api.github.com/repos/${REPO}/releases?per_page=${MAX_RELEASES}`; +const RELEASES_URL = `https://github.com/${REPO}/releases`; + +function fallback(reason) { + if (reason) console.warn(`[_data/release] using fallback — ${reason}`); + return { + available: false, + tag: null, + version: null, + url: RELEASES_URL, + releasesUrl: RELEASES_URL, + publishedAt: null, + publishedDate: null, + items: [], + recent: [], + }; +} + +function versionFromTag(tag) { + if (!tag) return null; + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +function formatDate(value) { + if (!value) return null; + return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" }).format( + new Date(value), + ); +} + +function mapRelease(data) { + const tag = data.tag_name; + const publishedAt = data.published_at || null; + return { + tag, + version: versionFromTag(tag), + title: data.name || tag, + url: data.html_url || RELEASES_URL, + publishedAt, + publishedDate: formatDate(publishedAt), + prerelease: Boolean(data.prerelease), + body: data.body || "", + }; +} + +export default async function () { + if (process.env.SKIP_RELEASE_FETCH === "1") { + return fallback("SKIP_RELEASE_FETCH=1"); + } + + const headers = { + "User-Agent": "LQL-website-build", + Accept: "application/vnd.github+json", + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + const response = await fetch(API_URL, { headers, signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + return fallback(`GitHub API responded ${response.status}`); + } + + const data = await response.json(); + const items = data.filter((item) => !item.draft).map(mapRelease); + const latest = items.find((item) => !item.prerelease) || items[0]; + if (!latest) { + return fallback("response missing releases"); + } + + return { + available: true, + tag: latest.tag, + version: latest.version, + url: latest.url, + releasesUrl: RELEASES_URL, + publishedAt: latest.publishedAt, + publishedDate: latest.publishedDate, + items, + recent: items.slice(0, RECENT_COUNT), + }; + } catch (err) { + return fallback(err.message); + } +} diff --git a/Lql/LqlWebsite-Eleventy/src/releases.njk b/Lql/LqlWebsite-Eleventy/src/releases.njk new file mode 100644 index 00000000..b4e2972b --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/src/releases.njk @@ -0,0 +1,46 @@ +--- +layout: layouts/base.njk +title: "Releases — LQL" +description: "Every release of the Lambda Query Language toolkit with full notes, generated at build time from the GitHub Releases." +permalink: /releases/ +--- + +{# + agent-pmo:795a9c2 + /releases/ page. Implements [WEB-RELEASES]. + Renders the build-time `release` data (src/_data/release.js). + Notes are rendered with the `releaseNotes` filter (markdown-it, html:false). +#} + +
+
+
+

Releases

+

Every release of the LQL toolkit with full notes — generated at build time from the GitHub Releases.

+

View all releases on GitHub ↗

+
+
+
+ +
+
+ {% if release.available and release.items.length %} + {% for item in release.items %} +
+

{{ item.title }} {{ item.tag }}

+

+ {% if item.publishedDate %}{% endif %} + {% if item.prerelease %}· Prerelease{% endif %} +

+ {% if item.body %} + {{ item.body | releaseNotes | safe }} + {% else %} +

Read the notes on GitHub

+ {% endif %} +
+ {% endfor %} + {% else %} +

Releases will appear here once the first version ships. View all releases on GitHub

+ {% endif %} +
+
diff --git a/Lql/Nimblesite.Lql.Browser/Services/FileDialogService.cs b/Lql/Nimblesite.Lql.Browser/Services/FileDialogService.cs index 68e3772f..0ec4e155 100644 --- a/Lql/Nimblesite.Lql.Browser/Services/FileDialogService.cs +++ b/Lql/Nimblesite.Lql.Browser/Services/FileDialogService.cs @@ -97,33 +97,22 @@ public static async Task> ShowSaveFileDialogAsync( string suggestedFileName ) { - try - { - var dialog = new FilePickerSaveOptions - { - Title = "Save File As", - SuggestedFileName = suggestedFileName, - FileTypeChoices = - [ - new FilePickerFileType("LQL Files") { Patterns = ["*.lql"] }, - new FilePickerFileType("SQL Files") { Patterns = ["*.sql"] }, - ], - }; - - var file = await storageProvider.SaveFilePickerAsync(dialog); - if (file != null) - { - return new Result.Ok(file.Path.LocalPath); - } - - return new Result.Error("No file selected"); - } - catch (Exception ex) + var dialog = new FilePickerSaveOptions { - return new Result.Error( - $"Error saving file: {ex.Message}" - ); - } + Title = "Save File As", + SuggestedFileName = suggestedFileName, + FileTypeChoices = + [ + new FilePickerFileType("LQL Files") { Patterns = ["*.lql"] }, + new FilePickerFileType("SQL Files") { Patterns = ["*.sql"] }, + ], + }; + + return await RunSavePickerAsync( + storageProvider: storageProvider, + dialog: dialog, + errorContext: "Error saving file" + ); } /// @@ -135,29 +124,18 @@ public static async Task> ShowExportCsvDialogAsync( IStorageProvider storageProvider ) { - try + var dialog = new FilePickerSaveOptions { - var dialog = new FilePickerSaveOptions - { - Title = "Export CSV", - SuggestedFileName = "export.csv", - FileTypeChoices = [new FilePickerFileType("CSV Files") { Patterns = ["*.csv"] }], - }; - - var file = await storageProvider.SaveFilePickerAsync(dialog); - if (file != null) - { - return new Result.Ok(file.Path.LocalPath); - } - - return new Result.Error("No file selected"); - } - catch (Exception ex) - { - return new Result.Error( - $"Error selecting export file: {ex.Message}" - ); - } + Title = "Export CSV", + SuggestedFileName = "export.csv", + FileTypeChoices = [new FilePickerFileType("CSV Files") { Patterns = ["*.csv"] }], + }; + + return await RunSavePickerAsync( + storageProvider: storageProvider, + dialog: dialog, + errorContext: "Error selecting export file" + ); } /// @@ -169,27 +147,37 @@ public static async Task> ShowExportJsonDialogAsync( IStorageProvider storageProvider ) { - try + var dialog = new FilePickerSaveOptions { - var dialog = new FilePickerSaveOptions - { - Title = "Export JSON", - SuggestedFileName = "export.json", - FileTypeChoices = [new FilePickerFileType("JSON Files") { Patterns = ["*.json"] }], - }; + Title = "Export JSON", + SuggestedFileName = "export.json", + FileTypeChoices = [new FilePickerFileType("JSON Files") { Patterns = ["*.json"] }], + }; + + return await RunSavePickerAsync( + storageProvider: storageProvider, + dialog: dialog, + errorContext: "Error selecting export file" + ); + } + private static async Task> RunSavePickerAsync( + IStorageProvider storageProvider, + FilePickerSaveOptions dialog, + string errorContext + ) + { + try + { var file = await storageProvider.SaveFilePickerAsync(dialog); - if (file != null) - { - return new Result.Ok(file.Path.LocalPath); - } - - return new Result.Error("No file selected"); + return file != null + ? new Result.Ok(file.Path.LocalPath) + : new Result.Error("No file selected"); } catch (Exception ex) { return new Result.Error( - $"Error selecting export file: {ex.Message}" + $"{errorContext}: {ex.Message}" ); } } diff --git a/Lql/Nimblesite.Lql.Core/JoinStep.cs b/Lql/Nimblesite.Lql.Core/JoinStep.cs index 13bfb425..393900f5 100644 --- a/Lql/Nimblesite.Lql.Core/JoinStep.cs +++ b/Lql/Nimblesite.Lql.Core/JoinStep.cs @@ -11,4 +11,10 @@ public sealed class JoinStep : StepBase /// Gets the join relationship containing table, condition, and join type. /// public required JoinRelationship JoinRelationship { get; init; } + + /// + /// Gets the parenthesized pipeline joined as a derived table, when the join source is + /// (pipeline) rather than a table name. Implements [LQL-DERIVED-TABLE]. + /// + public Pipeline? DerivedTable { get; init; } } diff --git a/Lql/Nimblesite.Lql.Core/LqlStatementValidator.cs b/Lql/Nimblesite.Lql.Core/LqlStatementValidator.cs new file mode 100644 index 00000000..0452862c --- /dev/null +++ b/Lql/Nimblesite.Lql.Core/LqlStatementValidator.cs @@ -0,0 +1,24 @@ +using Nimblesite.Sql.Model; + +namespace Nimblesite.Lql.Core; + +/// +/// Validates an before dialect conversion. +/// Shared by all platform-specific To*Sql extension methods to avoid duplicating +/// the parse-error and missing-AST guards. +/// +public static class LqlStatementValidator +{ + /// + /// Returns the reason the statement cannot be converted, or null when it is valid. + /// + /// The statement to validate. + /// + /// The parse error when present, a "no AST node" error when the statement has no AST node, + /// or null when the statement has a usable AST node. + /// + public static SqlError? Validate(LqlStatement statement) => + statement.ParseError is SqlError parseError ? parseError + : statement.AstNode is null ? new SqlError("No AST node found in statement") + : null; +} diff --git a/Lql/Nimblesite.Lql.Core/Parsing/Lql.g4 b/Lql/Nimblesite.Lql.Core/Parsing/Lql.g4 index 85d50d5e..8cf48d61 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/Lql.g4 +++ b/Lql/Nimblesite.Lql.Core/Parsing/Lql.g4 @@ -5,10 +5,20 @@ program ; statement - : letStmt + : withStmt + | letStmt | pipeExpr ; +// Common table expressions: with name as (pipeline), ... main pipeline +withStmt + : WITH cteDef (',' cteDef)* pipeExpr + ; + +cteDef + : IDENT AS '(' pipeExpr ')' + ; + letStmt : 'let' IDENT '=' pipeExpr ; diff --git a/Lql/Nimblesite.Lql.Core/Parsing/Lql.interp b/Lql/Nimblesite.Lql.Core/Parsing/Lql.interp index da9584bf..8af692b5 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/Lql.interp +++ b/Lql/Nimblesite.Lql.Core/Parsing/Lql.interp @@ -1,12 +1,12 @@ token literal names: null +',' +'(' +')' 'let' '=' '|>' -'(' -')' 'fn' -',' '=>' '.' '+' @@ -121,12 +121,16 @@ ASTERISK rule names: program statement +withStmt +cteDef letStmt pipeExpr expr windowSpec partitionClause orderClause +orderByArgList +orderByArg lambdaExpr qualifiedIdent argList @@ -152,4 +156,4 @@ comparisonOp atn: -[4, 1, 57, 370, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 1, 0, 5, 0, 62, 8, 0, 10, 0, 12, 0, 65, 9, 0, 1, 0, 1, 0, 1, 1, 1, 1, 3, 1, 71, 8, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 5, 3, 81, 8, 3, 10, 3, 12, 3, 84, 9, 3, 1, 4, 1, 4, 1, 4, 3, 4, 89, 8, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 3, 4, 100, 8, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 3, 4, 116, 8, 4, 1, 5, 3, 5, 119, 8, 5, 1, 5, 3, 5, 122, 8, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 8, 5, 8, 137, 8, 8, 10, 8, 12, 8, 140, 9, 8, 1, 8, 1, 8, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 4, 9, 149, 8, 9, 11, 9, 12, 9, 150, 1, 10, 1, 10, 1, 10, 5, 10, 156, 8, 10, 10, 10, 12, 10, 159, 9, 10, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 1, 11, 3, 11, 174, 8, 11, 1, 12, 1, 12, 1, 12, 1, 12, 3, 12, 180, 8, 12, 1, 12, 1, 12, 3, 12, 184, 8, 12, 1, 13, 1, 13, 1, 13, 5, 13, 189, 8, 13, 10, 13, 12, 13, 192, 9, 13, 1, 14, 1, 14, 1, 14, 5, 14, 197, 8, 14, 10, 14, 12, 14, 200, 9, 14, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 3, 15, 214, 8, 15, 1, 16, 1, 16, 1, 16, 3, 16, 219, 8, 16, 1, 16, 3, 16, 222, 8, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 3, 17, 230, 8, 17, 1, 18, 1, 18, 1, 18, 5, 18, 235, 8, 18, 10, 18, 12, 18, 238, 9, 18, 1, 19, 1, 19, 1, 19, 5, 19, 243, 8, 19, 10, 19, 12, 19, 246, 9, 19, 1, 20, 1, 20, 1, 20, 1, 20, 1, 20, 3, 20, 253, 8, 20, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 3, 21, 267, 8, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 3, 21, 277, 8, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 3, 21, 287, 8, 21, 1, 21, 1, 21, 3, 21, 291, 8, 21, 1, 21, 1, 21, 3, 21, 295, 8, 21, 1, 21, 1, 21, 3, 21, 299, 8, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 3, 21, 308, 8, 21, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 3, 23, 318, 8, 23, 1, 23, 1, 23, 3, 23, 322, 8, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 3, 24, 329, 8, 24, 1, 24, 1, 24, 1, 24, 1, 24, 3, 24, 335, 8, 24, 1, 24, 1, 24, 1, 25, 1, 25, 4, 25, 341, 8, 25, 11, 25, 12, 25, 342, 1, 25, 1, 25, 3, 25, 347, 8, 25, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 3, 27, 364, 8, 27, 1, 28, 1, 28, 1, 29, 1, 29, 1, 29, 0, 0, 30, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 0, 5, 1, 0, 10, 12, 2, 0, 13, 14, 57, 57, 2, 0, 48, 48, 51, 51, 1, 0, 21, 22, 3, 0, 2, 2, 15, 20, 49, 49, 435, 0, 63, 1, 0, 0, 0, 2, 70, 1, 0, 0, 0, 4, 72, 1, 0, 0, 0, 6, 77, 1, 0, 0, 0, 8, 115, 1, 0, 0, 0, 10, 118, 1, 0, 0, 0, 12, 123, 1, 0, 0, 0, 14, 127, 1, 0, 0, 0, 16, 131, 1, 0, 0, 0, 18, 145, 1, 0, 0, 0, 20, 152, 1, 0, 0, 0, 22, 173, 1, 0, 0, 0, 24, 179, 1, 0, 0, 0, 26, 185, 1, 0, 0, 0, 28, 193, 1, 0, 0, 0, 30, 213, 1, 0, 0, 0, 32, 215, 1, 0, 0, 0, 34, 225, 1, 0, 0, 0, 36, 231, 1, 0, 0, 0, 38, 239, 1, 0, 0, 0, 40, 252, 1, 0, 0, 0, 42, 307, 1, 0, 0, 0, 44, 309, 1, 0, 0, 0, 46, 317, 1, 0, 0, 0, 48, 328, 1, 0, 0, 0, 50, 338, 1, 0, 0, 0, 52, 350, 1, 0, 0, 0, 54, 363, 1, 0, 0, 0, 56, 365, 1, 0, 0, 0, 58, 367, 1, 0, 0, 0, 60, 62, 3, 2, 1, 0, 61, 60, 1, 0, 0, 0, 62, 65, 1, 0, 0, 0, 63, 61, 1, 0, 0, 0, 63, 64, 1, 0, 0, 0, 64, 66, 1, 0, 0, 0, 65, 63, 1, 0, 0, 0, 66, 67, 5, 0, 0, 1, 67, 1, 1, 0, 0, 0, 68, 71, 3, 4, 2, 0, 69, 71, 3, 6, 3, 0, 70, 68, 1, 0, 0, 0, 70, 69, 1, 0, 0, 0, 71, 3, 1, 0, 0, 0, 72, 73, 5, 1, 0, 0, 73, 74, 5, 51, 0, 0, 74, 75, 5, 2, 0, 0, 75, 76, 3, 6, 3, 0, 76, 5, 1, 0, 0, 0, 77, 82, 3, 8, 4, 0, 78, 79, 5, 3, 0, 0, 79, 81, 3, 8, 4, 0, 80, 78, 1, 0, 0, 0, 81, 84, 1, 0, 0, 0, 82, 80, 1, 0, 0, 0, 82, 83, 1, 0, 0, 0, 83, 7, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 85, 86, 5, 51, 0, 0, 86, 88, 5, 4, 0, 0, 87, 89, 3, 20, 10, 0, 88, 87, 1, 0, 0, 0, 88, 89, 1, 0, 0, 0, 89, 90, 1, 0, 0, 0, 90, 91, 5, 5, 0, 0, 91, 92, 5, 38, 0, 0, 92, 93, 5, 4, 0, 0, 93, 94, 3, 10, 5, 0, 94, 95, 5, 5, 0, 0, 95, 116, 1, 0, 0, 0, 96, 97, 5, 51, 0, 0, 97, 99, 5, 4, 0, 0, 98, 100, 3, 20, 10, 0, 99, 98, 1, 0, 0, 0, 99, 100, 1, 0, 0, 0, 100, 101, 1, 0, 0, 0, 101, 116, 5, 5, 0, 0, 102, 116, 5, 51, 0, 0, 103, 104, 5, 4, 0, 0, 104, 105, 3, 6, 3, 0, 105, 106, 5, 5, 0, 0, 106, 116, 1, 0, 0, 0, 107, 116, 3, 18, 9, 0, 108, 116, 3, 16, 8, 0, 109, 116, 3, 50, 25, 0, 110, 116, 5, 52, 0, 0, 111, 116, 5, 53, 0, 0, 112, 116, 5, 57, 0, 0, 113, 116, 5, 54, 0, 0, 114, 116, 5, 50, 0, 0, 115, 85, 1, 0, 0, 0, 115, 96, 1, 0, 0, 0, 115, 102, 1, 0, 0, 0, 115, 103, 1, 0, 0, 0, 115, 107, 1, 0, 0, 0, 115, 108, 1, 0, 0, 0, 115, 109, 1, 0, 0, 0, 115, 110, 1, 0, 0, 0, 115, 111, 1, 0, 0, 0, 115, 112, 1, 0, 0, 0, 115, 113, 1, 0, 0, 0, 115, 114, 1, 0, 0, 0, 116, 9, 1, 0, 0, 0, 117, 119, 3, 12, 6, 0, 118, 117, 1, 0, 0, 0, 118, 119, 1, 0, 0, 0, 119, 121, 1, 0, 0, 0, 120, 122, 3, 14, 7, 0, 121, 120, 1, 0, 0, 0, 121, 122, 1, 0, 0, 0, 122, 11, 1, 0, 0, 0, 123, 124, 5, 39, 0, 0, 124, 125, 5, 41, 0, 0, 125, 126, 3, 20, 10, 0, 126, 13, 1, 0, 0, 0, 127, 128, 5, 40, 0, 0, 128, 129, 5, 41, 0, 0, 129, 130, 3, 20, 10, 0, 130, 15, 1, 0, 0, 0, 131, 132, 5, 6, 0, 0, 132, 133, 5, 4, 0, 0, 133, 138, 5, 51, 0, 0, 134, 135, 5, 7, 0, 0, 135, 137, 5, 51, 0, 0, 136, 134, 1, 0, 0, 0, 137, 140, 1, 0, 0, 0, 138, 136, 1, 0, 0, 0, 138, 139, 1, 0, 0, 0, 139, 141, 1, 0, 0, 0, 140, 138, 1, 0, 0, 0, 141, 142, 5, 5, 0, 0, 142, 143, 5, 8, 0, 0, 143, 144, 3, 36, 18, 0, 144, 17, 1, 0, 0, 0, 145, 148, 5, 51, 0, 0, 146, 147, 5, 9, 0, 0, 147, 149, 5, 51, 0, 0, 148, 146, 1, 0, 0, 0, 149, 150, 1, 0, 0, 0, 150, 148, 1, 0, 0, 0, 150, 151, 1, 0, 0, 0, 151, 19, 1, 0, 0, 0, 152, 157, 3, 22, 11, 0, 153, 154, 5, 7, 0, 0, 154, 156, 3, 22, 11, 0, 155, 153, 1, 0, 0, 0, 156, 159, 1, 0, 0, 0, 157, 155, 1, 0, 0, 0, 157, 158, 1, 0, 0, 0, 158, 21, 1, 0, 0, 0, 159, 157, 1, 0, 0, 0, 160, 174, 3, 24, 12, 0, 161, 174, 3, 26, 13, 0, 162, 174, 3, 32, 16, 0, 163, 174, 3, 50, 25, 0, 164, 174, 3, 8, 4, 0, 165, 174, 3, 34, 17, 0, 166, 174, 3, 42, 21, 0, 167, 174, 3, 6, 3, 0, 168, 174, 3, 16, 8, 0, 169, 170, 5, 4, 0, 0, 170, 171, 3, 6, 3, 0, 171, 172, 5, 5, 0, 0, 172, 174, 1, 0, 0, 0, 173, 160, 1, 0, 0, 0, 173, 161, 1, 0, 0, 0, 173, 162, 1, 0, 0, 0, 173, 163, 1, 0, 0, 0, 173, 164, 1, 0, 0, 0, 173, 165, 1, 0, 0, 0, 173, 166, 1, 0, 0, 0, 173, 167, 1, 0, 0, 0, 173, 168, 1, 0, 0, 0, 173, 169, 1, 0, 0, 0, 174, 23, 1, 0, 0, 0, 175, 180, 3, 26, 13, 0, 176, 180, 3, 32, 16, 0, 177, 180, 3, 18, 9, 0, 178, 180, 5, 51, 0, 0, 179, 175, 1, 0, 0, 0, 179, 176, 1, 0, 0, 0, 179, 177, 1, 0, 0, 0, 179, 178, 1, 0, 0, 0, 180, 183, 1, 0, 0, 0, 181, 182, 5, 31, 0, 0, 182, 184, 5, 51, 0, 0, 183, 181, 1, 0, 0, 0, 183, 184, 1, 0, 0, 0, 184, 25, 1, 0, 0, 0, 185, 190, 3, 28, 14, 0, 186, 187, 7, 0, 0, 0, 187, 189, 3, 28, 14, 0, 188, 186, 1, 0, 0, 0, 189, 192, 1, 0, 0, 0, 190, 188, 1, 0, 0, 0, 190, 191, 1, 0, 0, 0, 191, 27, 1, 0, 0, 0, 192, 190, 1, 0, 0, 0, 193, 198, 3, 30, 15, 0, 194, 195, 7, 1, 0, 0, 195, 197, 3, 30, 15, 0, 196, 194, 1, 0, 0, 0, 197, 200, 1, 0, 0, 0, 198, 196, 1, 0, 0, 0, 198, 199, 1, 0, 0, 0, 199, 29, 1, 0, 0, 0, 200, 198, 1, 0, 0, 0, 201, 214, 3, 18, 9, 0, 202, 214, 5, 51, 0, 0, 203, 214, 5, 52, 0, 0, 204, 214, 5, 53, 0, 0, 205, 214, 5, 54, 0, 0, 206, 214, 3, 32, 16, 0, 207, 214, 3, 50, 25, 0, 208, 214, 5, 50, 0, 0, 209, 210, 5, 4, 0, 0, 210, 211, 3, 26, 13, 0, 211, 212, 5, 5, 0, 0, 212, 214, 1, 0, 0, 0, 213, 201, 1, 0, 0, 0, 213, 202, 1, 0, 0, 0, 213, 203, 1, 0, 0, 0, 213, 204, 1, 0, 0, 0, 213, 205, 1, 0, 0, 0, 213, 206, 1, 0, 0, 0, 213, 207, 1, 0, 0, 0, 213, 208, 1, 0, 0, 0, 213, 209, 1, 0, 0, 0, 214, 31, 1, 0, 0, 0, 215, 216, 5, 51, 0, 0, 216, 221, 5, 4, 0, 0, 217, 219, 5, 25, 0, 0, 218, 217, 1, 0, 0, 0, 218, 219, 1, 0, 0, 0, 219, 220, 1, 0, 0, 0, 220, 222, 3, 20, 10, 0, 221, 218, 1, 0, 0, 0, 221, 222, 1, 0, 0, 0, 222, 223, 1, 0, 0, 0, 223, 224, 5, 5, 0, 0, 224, 33, 1, 0, 0, 0, 225, 226, 7, 2, 0, 0, 226, 229, 5, 2, 0, 0, 227, 230, 3, 42, 21, 0, 228, 230, 3, 36, 18, 0, 229, 227, 1, 0, 0, 0, 229, 228, 1, 0, 0, 0, 230, 35, 1, 0, 0, 0, 231, 236, 3, 38, 19, 0, 232, 233, 5, 24, 0, 0, 233, 235, 3, 38, 19, 0, 234, 232, 1, 0, 0, 0, 235, 238, 1, 0, 0, 0, 236, 234, 1, 0, 0, 0, 236, 237, 1, 0, 0, 0, 237, 37, 1, 0, 0, 0, 238, 236, 1, 0, 0, 0, 239, 244, 3, 40, 20, 0, 240, 241, 5, 23, 0, 0, 241, 243, 3, 40, 20, 0, 242, 240, 1, 0, 0, 0, 243, 246, 1, 0, 0, 0, 244, 242, 1, 0, 0, 0, 244, 245, 1, 0, 0, 0, 245, 39, 1, 0, 0, 0, 246, 244, 1, 0, 0, 0, 247, 253, 3, 42, 21, 0, 248, 249, 5, 4, 0, 0, 249, 250, 3, 36, 18, 0, 250, 251, 5, 5, 0, 0, 251, 253, 1, 0, 0, 0, 252, 247, 1, 0, 0, 0, 252, 248, 1, 0, 0, 0, 253, 41, 1, 0, 0, 0, 254, 255, 3, 26, 13, 0, 255, 256, 3, 58, 29, 0, 256, 257, 3, 26, 13, 0, 257, 308, 1, 0, 0, 0, 258, 259, 3, 18, 9, 0, 259, 266, 3, 58, 29, 0, 260, 267, 3, 18, 9, 0, 261, 267, 5, 54, 0, 0, 262, 267, 5, 51, 0, 0, 263, 267, 5, 52, 0, 0, 264, 267, 5, 53, 0, 0, 265, 267, 5, 50, 0, 0, 266, 260, 1, 0, 0, 0, 266, 261, 1, 0, 0, 0, 266, 262, 1, 0, 0, 0, 266, 263, 1, 0, 0, 0, 266, 264, 1, 0, 0, 0, 266, 265, 1, 0, 0, 0, 267, 308, 1, 0, 0, 0, 268, 269, 5, 51, 0, 0, 269, 276, 3, 58, 29, 0, 270, 277, 3, 18, 9, 0, 271, 277, 5, 54, 0, 0, 272, 277, 5, 51, 0, 0, 273, 277, 5, 52, 0, 0, 274, 277, 5, 53, 0, 0, 275, 277, 5, 50, 0, 0, 276, 270, 1, 0, 0, 0, 276, 271, 1, 0, 0, 0, 276, 272, 1, 0, 0, 0, 276, 273, 1, 0, 0, 0, 276, 274, 1, 0, 0, 0, 276, 275, 1, 0, 0, 0, 277, 308, 1, 0, 0, 0, 278, 279, 5, 50, 0, 0, 279, 286, 3, 58, 29, 0, 280, 287, 3, 18, 9, 0, 281, 287, 5, 54, 0, 0, 282, 287, 5, 51, 0, 0, 283, 287, 5, 52, 0, 0, 284, 287, 5, 53, 0, 0, 285, 287, 5, 50, 0, 0, 286, 280, 1, 0, 0, 0, 286, 281, 1, 0, 0, 0, 286, 282, 1, 0, 0, 0, 286, 283, 1, 0, 0, 0, 286, 284, 1, 0, 0, 0, 286, 285, 1, 0, 0, 0, 287, 308, 1, 0, 0, 0, 288, 290, 3, 18, 9, 0, 289, 291, 3, 56, 28, 0, 290, 289, 1, 0, 0, 0, 290, 291, 1, 0, 0, 0, 291, 308, 1, 0, 0, 0, 292, 294, 5, 51, 0, 0, 293, 295, 3, 56, 28, 0, 294, 293, 1, 0, 0, 0, 294, 295, 1, 0, 0, 0, 295, 308, 1, 0, 0, 0, 296, 298, 5, 50, 0, 0, 297, 299, 3, 56, 28, 0, 298, 297, 1, 0, 0, 0, 298, 299, 1, 0, 0, 0, 299, 308, 1, 0, 0, 0, 300, 308, 5, 54, 0, 0, 301, 308, 5, 52, 0, 0, 302, 308, 5, 53, 0, 0, 303, 308, 3, 8, 4, 0, 304, 308, 3, 44, 22, 0, 305, 308, 3, 46, 23, 0, 306, 308, 3, 48, 24, 0, 307, 254, 1, 0, 0, 0, 307, 258, 1, 0, 0, 0, 307, 268, 1, 0, 0, 0, 307, 278, 1, 0, 0, 0, 307, 288, 1, 0, 0, 0, 307, 292, 1, 0, 0, 0, 307, 296, 1, 0, 0, 0, 307, 300, 1, 0, 0, 0, 307, 301, 1, 0, 0, 0, 307, 302, 1, 0, 0, 0, 307, 303, 1, 0, 0, 0, 307, 304, 1, 0, 0, 0, 307, 305, 1, 0, 0, 0, 307, 306, 1, 0, 0, 0, 308, 43, 1, 0, 0, 0, 309, 310, 5, 26, 0, 0, 310, 311, 5, 4, 0, 0, 311, 312, 3, 6, 3, 0, 312, 313, 5, 5, 0, 0, 313, 45, 1, 0, 0, 0, 314, 318, 3, 18, 9, 0, 315, 318, 5, 51, 0, 0, 316, 318, 5, 50, 0, 0, 317, 314, 1, 0, 0, 0, 317, 315, 1, 0, 0, 0, 317, 316, 1, 0, 0, 0, 318, 319, 1, 0, 0, 0, 319, 321, 5, 28, 0, 0, 320, 322, 5, 29, 0, 0, 321, 320, 1, 0, 0, 0, 321, 322, 1, 0, 0, 0, 322, 323, 1, 0, 0, 0, 323, 324, 5, 27, 0, 0, 324, 47, 1, 0, 0, 0, 325, 329, 3, 18, 9, 0, 326, 329, 5, 51, 0, 0, 327, 329, 5, 50, 0, 0, 328, 325, 1, 0, 0, 0, 328, 326, 1, 0, 0, 0, 328, 327, 1, 0, 0, 0, 329, 330, 1, 0, 0, 0, 330, 331, 5, 30, 0, 0, 331, 334, 5, 4, 0, 0, 332, 335, 3, 6, 3, 0, 333, 335, 3, 20, 10, 0, 334, 332, 1, 0, 0, 0, 334, 333, 1, 0, 0, 0, 335, 336, 1, 0, 0, 0, 336, 337, 5, 5, 0, 0, 337, 49, 1, 0, 0, 0, 338, 340, 5, 32, 0, 0, 339, 341, 3, 52, 26, 0, 340, 339, 1, 0, 0, 0, 341, 342, 1, 0, 0, 0, 342, 340, 1, 0, 0, 0, 342, 343, 1, 0, 0, 0, 343, 346, 1, 0, 0, 0, 344, 345, 5, 35, 0, 0, 345, 347, 3, 54, 27, 0, 346, 344, 1, 0, 0, 0, 346, 347, 1, 0, 0, 0, 347, 348, 1, 0, 0, 0, 348, 349, 5, 36, 0, 0, 349, 51, 1, 0, 0, 0, 350, 351, 5, 33, 0, 0, 351, 352, 3, 42, 21, 0, 352, 353, 5, 34, 0, 0, 353, 354, 3, 54, 27, 0, 354, 53, 1, 0, 0, 0, 355, 364, 3, 26, 13, 0, 356, 364, 3, 42, 21, 0, 357, 364, 3, 18, 9, 0, 358, 364, 5, 51, 0, 0, 359, 364, 5, 52, 0, 0, 360, 364, 5, 53, 0, 0, 361, 364, 5, 54, 0, 0, 362, 364, 5, 50, 0, 0, 363, 355, 1, 0, 0, 0, 363, 356, 1, 0, 0, 0, 363, 357, 1, 0, 0, 0, 363, 358, 1, 0, 0, 0, 363, 359, 1, 0, 0, 0, 363, 360, 1, 0, 0, 0, 363, 361, 1, 0, 0, 0, 363, 362, 1, 0, 0, 0, 364, 55, 1, 0, 0, 0, 365, 366, 7, 3, 0, 0, 366, 57, 1, 0, 0, 0, 367, 368, 7, 4, 0, 0, 368, 59, 1, 0, 0, 0, 37, 63, 70, 82, 88, 99, 115, 118, 121, 138, 150, 157, 173, 179, 183, 190, 198, 213, 218, 221, 229, 236, 244, 252, 266, 276, 286, 290, 294, 298, 307, 317, 321, 328, 334, 342, 346, 363] \ No newline at end of file +[4, 1, 57, 419, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2, 31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 1, 0, 5, 0, 70, 8, 0, 10, 0, 12, 0, 73, 9, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 3, 1, 80, 8, 1, 1, 2, 1, 2, 1, 2, 1, 2, 5, 2, 86, 8, 2, 10, 2, 12, 2, 89, 9, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 3, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 5, 5, 107, 8, 5, 10, 5, 12, 5, 110, 9, 5, 1, 6, 1, 6, 1, 6, 3, 6, 115, 8, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 126, 8, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 142, 8, 6, 1, 7, 3, 7, 145, 8, 7, 1, 7, 3, 7, 148, 8, 7, 1, 8, 1, 8, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 10, 5, 10, 161, 8, 10, 10, 10, 12, 10, 164, 9, 10, 1, 11, 1, 11, 1, 11, 1, 11, 3, 11, 170, 8, 11, 1, 11, 3, 11, 173, 8, 11, 1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 5, 12, 180, 8, 12, 10, 12, 12, 12, 183, 9, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 4, 13, 192, 8, 13, 11, 13, 12, 13, 193, 1, 14, 1, 14, 1, 14, 5, 14, 199, 8, 14, 10, 14, 12, 14, 202, 9, 14, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 1, 15, 3, 15, 217, 8, 15, 1, 16, 1, 16, 1, 16, 1, 16, 3, 16, 223, 8, 16, 1, 16, 1, 16, 3, 16, 227, 8, 16, 1, 17, 1, 17, 1, 17, 5, 17, 232, 8, 17, 10, 17, 12, 17, 235, 9, 17, 1, 18, 1, 18, 1, 18, 5, 18, 240, 8, 18, 10, 18, 12, 18, 243, 9, 18, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 3, 19, 257, 8, 19, 1, 20, 1, 20, 1, 20, 3, 20, 262, 8, 20, 1, 20, 3, 20, 265, 8, 20, 1, 20, 1, 20, 1, 20, 1, 20, 1, 20, 1, 20, 3, 20, 273, 8, 20, 1, 21, 1, 21, 1, 21, 1, 21, 3, 21, 279, 8, 21, 1, 22, 1, 22, 1, 22, 5, 22, 284, 8, 22, 10, 22, 12, 22, 287, 9, 22, 1, 23, 1, 23, 1, 23, 5, 23, 292, 8, 23, 10, 23, 12, 23, 295, 9, 23, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 3, 24, 302, 8, 24, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 3, 25, 316, 8, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 3, 25, 326, 8, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 3, 25, 336, 8, 25, 1, 25, 1, 25, 3, 25, 340, 8, 25, 1, 25, 1, 25, 3, 25, 344, 8, 25, 1, 25, 1, 25, 3, 25, 348, 8, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 3, 25, 357, 8, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 27, 1, 27, 1, 27, 3, 27, 367, 8, 27, 1, 27, 1, 27, 3, 27, 371, 8, 27, 1, 27, 1, 27, 1, 28, 1, 28, 1, 28, 3, 28, 378, 8, 28, 1, 28, 1, 28, 1, 28, 1, 28, 3, 28, 384, 8, 28, 1, 28, 1, 28, 1, 29, 1, 29, 4, 29, 390, 8, 29, 11, 29, 12, 29, 391, 1, 29, 1, 29, 3, 29, 396, 8, 29, 1, 29, 1, 29, 1, 30, 1, 30, 1, 30, 1, 30, 1, 30, 1, 31, 1, 31, 1, 31, 1, 31, 1, 31, 1, 31, 1, 31, 1, 31, 3, 31, 413, 8, 31, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 0, 0, 34, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 0, 5, 1, 0, 10, 12, 2, 0, 13, 14, 57, 57, 2, 0, 48, 48, 51, 51, 1, 0, 21, 22, 3, 0, 5, 5, 15, 20, 49, 49, 488, 0, 71, 1, 0, 0, 0, 2, 79, 1, 0, 0, 0, 4, 81, 1, 0, 0, 0, 6, 92, 1, 0, 0, 0, 8, 98, 1, 0, 0, 0, 10, 103, 1, 0, 0, 0, 12, 141, 1, 0, 0, 0, 14, 144, 1, 0, 0, 0, 16, 149, 1, 0, 0, 0, 18, 153, 1, 0, 0, 0, 20, 157, 1, 0, 0, 0, 22, 169, 1, 0, 0, 0, 24, 174, 1, 0, 0, 0, 26, 188, 1, 0, 0, 0, 28, 195, 1, 0, 0, 0, 30, 216, 1, 0, 0, 0, 32, 222, 1, 0, 0, 0, 34, 228, 1, 0, 0, 0, 36, 236, 1, 0, 0, 0, 38, 256, 1, 0, 0, 0, 40, 258, 1, 0, 0, 0, 42, 274, 1, 0, 0, 0, 44, 280, 1, 0, 0, 0, 46, 288, 1, 0, 0, 0, 48, 301, 1, 0, 0, 0, 50, 356, 1, 0, 0, 0, 52, 358, 1, 0, 0, 0, 54, 366, 1, 0, 0, 0, 56, 377, 1, 0, 0, 0, 58, 387, 1, 0, 0, 0, 60, 399, 1, 0, 0, 0, 62, 412, 1, 0, 0, 0, 64, 414, 1, 0, 0, 0, 66, 416, 1, 0, 0, 0, 68, 70, 3, 2, 1, 0, 69, 68, 1, 0, 0, 0, 70, 73, 1, 0, 0, 0, 71, 69, 1, 0, 0, 0, 71, 72, 1, 0, 0, 0, 72, 74, 1, 0, 0, 0, 73, 71, 1, 0, 0, 0, 74, 75, 5, 0, 0, 1, 75, 1, 1, 0, 0, 0, 76, 80, 3, 4, 2, 0, 77, 80, 3, 8, 4, 0, 78, 80, 3, 10, 5, 0, 79, 76, 1, 0, 0, 0, 79, 77, 1, 0, 0, 0, 79, 78, 1, 0, 0, 0, 80, 3, 1, 0, 0, 0, 81, 82, 5, 37, 0, 0, 82, 87, 3, 6, 3, 0, 83, 84, 5, 1, 0, 0, 84, 86, 3, 6, 3, 0, 85, 83, 1, 0, 0, 0, 86, 89, 1, 0, 0, 0, 87, 85, 1, 0, 0, 0, 87, 88, 1, 0, 0, 0, 88, 90, 1, 0, 0, 0, 89, 87, 1, 0, 0, 0, 90, 91, 3, 10, 5, 0, 91, 5, 1, 0, 0, 0, 92, 93, 5, 51, 0, 0, 93, 94, 5, 31, 0, 0, 94, 95, 5, 2, 0, 0, 95, 96, 3, 10, 5, 0, 96, 97, 5, 3, 0, 0, 97, 7, 1, 0, 0, 0, 98, 99, 5, 4, 0, 0, 99, 100, 5, 51, 0, 0, 100, 101, 5, 5, 0, 0, 101, 102, 3, 10, 5, 0, 102, 9, 1, 0, 0, 0, 103, 108, 3, 12, 6, 0, 104, 105, 5, 6, 0, 0, 105, 107, 3, 12, 6, 0, 106, 104, 1, 0, 0, 0, 107, 110, 1, 0, 0, 0, 108, 106, 1, 0, 0, 0, 108, 109, 1, 0, 0, 0, 109, 11, 1, 0, 0, 0, 110, 108, 1, 0, 0, 0, 111, 112, 5, 51, 0, 0, 112, 114, 5, 2, 0, 0, 113, 115, 3, 28, 14, 0, 114, 113, 1, 0, 0, 0, 114, 115, 1, 0, 0, 0, 115, 116, 1, 0, 0, 0, 116, 117, 5, 3, 0, 0, 117, 118, 5, 38, 0, 0, 118, 119, 5, 2, 0, 0, 119, 120, 3, 14, 7, 0, 120, 121, 5, 3, 0, 0, 121, 142, 1, 0, 0, 0, 122, 123, 5, 51, 0, 0, 123, 125, 5, 2, 0, 0, 124, 126, 3, 28, 14, 0, 125, 124, 1, 0, 0, 0, 125, 126, 1, 0, 0, 0, 126, 127, 1, 0, 0, 0, 127, 142, 5, 3, 0, 0, 128, 142, 5, 51, 0, 0, 129, 130, 5, 2, 0, 0, 130, 131, 3, 10, 5, 0, 131, 132, 5, 3, 0, 0, 132, 142, 1, 0, 0, 0, 133, 142, 3, 26, 13, 0, 134, 142, 3, 24, 12, 0, 135, 142, 3, 58, 29, 0, 136, 142, 5, 52, 0, 0, 137, 142, 5, 53, 0, 0, 138, 142, 5, 57, 0, 0, 139, 142, 5, 54, 0, 0, 140, 142, 5, 50, 0, 0, 141, 111, 1, 0, 0, 0, 141, 122, 1, 0, 0, 0, 141, 128, 1, 0, 0, 0, 141, 129, 1, 0, 0, 0, 141, 133, 1, 0, 0, 0, 141, 134, 1, 0, 0, 0, 141, 135, 1, 0, 0, 0, 141, 136, 1, 0, 0, 0, 141, 137, 1, 0, 0, 0, 141, 138, 1, 0, 0, 0, 141, 139, 1, 0, 0, 0, 141, 140, 1, 0, 0, 0, 142, 13, 1, 0, 0, 0, 143, 145, 3, 16, 8, 0, 144, 143, 1, 0, 0, 0, 144, 145, 1, 0, 0, 0, 145, 147, 1, 0, 0, 0, 146, 148, 3, 18, 9, 0, 147, 146, 1, 0, 0, 0, 147, 148, 1, 0, 0, 0, 148, 15, 1, 0, 0, 0, 149, 150, 5, 39, 0, 0, 150, 151, 5, 41, 0, 0, 151, 152, 3, 28, 14, 0, 152, 17, 1, 0, 0, 0, 153, 154, 5, 40, 0, 0, 154, 155, 5, 41, 0, 0, 155, 156, 3, 20, 10, 0, 156, 19, 1, 0, 0, 0, 157, 162, 3, 22, 11, 0, 158, 159, 5, 1, 0, 0, 159, 161, 3, 22, 11, 0, 160, 158, 1, 0, 0, 0, 161, 164, 1, 0, 0, 0, 162, 160, 1, 0, 0, 0, 162, 163, 1, 0, 0, 0, 163, 21, 1, 0, 0, 0, 164, 162, 1, 0, 0, 0, 165, 170, 3, 34, 17, 0, 166, 170, 3, 40, 20, 0, 167, 170, 3, 26, 13, 0, 168, 170, 5, 51, 0, 0, 169, 165, 1, 0, 0, 0, 169, 166, 1, 0, 0, 0, 169, 167, 1, 0, 0, 0, 169, 168, 1, 0, 0, 0, 170, 172, 1, 0, 0, 0, 171, 173, 3, 64, 32, 0, 172, 171, 1, 0, 0, 0, 172, 173, 1, 0, 0, 0, 173, 23, 1, 0, 0, 0, 174, 175, 5, 7, 0, 0, 175, 176, 5, 2, 0, 0, 176, 181, 5, 51, 0, 0, 177, 178, 5, 1, 0, 0, 178, 180, 5, 51, 0, 0, 179, 177, 1, 0, 0, 0, 180, 183, 1, 0, 0, 0, 181, 179, 1, 0, 0, 0, 181, 182, 1, 0, 0, 0, 182, 184, 1, 0, 0, 0, 183, 181, 1, 0, 0, 0, 184, 185, 5, 3, 0, 0, 185, 186, 5, 8, 0, 0, 186, 187, 3, 44, 22, 0, 187, 25, 1, 0, 0, 0, 188, 191, 5, 51, 0, 0, 189, 190, 5, 9, 0, 0, 190, 192, 5, 51, 0, 0, 191, 189, 1, 0, 0, 0, 192, 193, 1, 0, 0, 0, 193, 191, 1, 0, 0, 0, 193, 194, 1, 0, 0, 0, 194, 27, 1, 0, 0, 0, 195, 200, 3, 30, 15, 0, 196, 197, 5, 1, 0, 0, 197, 199, 3, 30, 15, 0, 198, 196, 1, 0, 0, 0, 199, 202, 1, 0, 0, 0, 200, 198, 1, 0, 0, 0, 200, 201, 1, 0, 0, 0, 201, 29, 1, 0, 0, 0, 202, 200, 1, 0, 0, 0, 203, 217, 3, 32, 16, 0, 204, 217, 3, 34, 17, 0, 205, 217, 3, 40, 20, 0, 206, 217, 3, 58, 29, 0, 207, 217, 3, 12, 6, 0, 208, 217, 3, 42, 21, 0, 209, 217, 3, 50, 25, 0, 210, 217, 3, 10, 5, 0, 211, 217, 3, 24, 12, 0, 212, 213, 5, 2, 0, 0, 213, 214, 3, 10, 5, 0, 214, 215, 5, 3, 0, 0, 215, 217, 1, 0, 0, 0, 216, 203, 1, 0, 0, 0, 216, 204, 1, 0, 0, 0, 216, 205, 1, 0, 0, 0, 216, 206, 1, 0, 0, 0, 216, 207, 1, 0, 0, 0, 216, 208, 1, 0, 0, 0, 216, 209, 1, 0, 0, 0, 216, 210, 1, 0, 0, 0, 216, 211, 1, 0, 0, 0, 216, 212, 1, 0, 0, 0, 217, 31, 1, 0, 0, 0, 218, 223, 3, 34, 17, 0, 219, 223, 3, 40, 20, 0, 220, 223, 3, 26, 13, 0, 221, 223, 5, 51, 0, 0, 222, 218, 1, 0, 0, 0, 222, 219, 1, 0, 0, 0, 222, 220, 1, 0, 0, 0, 222, 221, 1, 0, 0, 0, 223, 226, 1, 0, 0, 0, 224, 225, 5, 31, 0, 0, 225, 227, 5, 51, 0, 0, 226, 224, 1, 0, 0, 0, 226, 227, 1, 0, 0, 0, 227, 33, 1, 0, 0, 0, 228, 233, 3, 36, 18, 0, 229, 230, 7, 0, 0, 0, 230, 232, 3, 36, 18, 0, 231, 229, 1, 0, 0, 0, 232, 235, 1, 0, 0, 0, 233, 231, 1, 0, 0, 0, 233, 234, 1, 0, 0, 0, 234, 35, 1, 0, 0, 0, 235, 233, 1, 0, 0, 0, 236, 241, 3, 38, 19, 0, 237, 238, 7, 1, 0, 0, 238, 240, 3, 38, 19, 0, 239, 237, 1, 0, 0, 0, 240, 243, 1, 0, 0, 0, 241, 239, 1, 0, 0, 0, 241, 242, 1, 0, 0, 0, 242, 37, 1, 0, 0, 0, 243, 241, 1, 0, 0, 0, 244, 257, 3, 26, 13, 0, 245, 257, 5, 51, 0, 0, 246, 257, 5, 52, 0, 0, 247, 257, 5, 53, 0, 0, 248, 257, 5, 54, 0, 0, 249, 257, 3, 40, 20, 0, 250, 257, 3, 58, 29, 0, 251, 257, 5, 50, 0, 0, 252, 253, 5, 2, 0, 0, 253, 254, 3, 34, 17, 0, 254, 255, 5, 3, 0, 0, 255, 257, 1, 0, 0, 0, 256, 244, 1, 0, 0, 0, 256, 245, 1, 0, 0, 0, 256, 246, 1, 0, 0, 0, 256, 247, 1, 0, 0, 0, 256, 248, 1, 0, 0, 0, 256, 249, 1, 0, 0, 0, 256, 250, 1, 0, 0, 0, 256, 251, 1, 0, 0, 0, 256, 252, 1, 0, 0, 0, 257, 39, 1, 0, 0, 0, 258, 259, 5, 51, 0, 0, 259, 264, 5, 2, 0, 0, 260, 262, 5, 25, 0, 0, 261, 260, 1, 0, 0, 0, 261, 262, 1, 0, 0, 0, 262, 263, 1, 0, 0, 0, 263, 265, 3, 28, 14, 0, 264, 261, 1, 0, 0, 0, 264, 265, 1, 0, 0, 0, 265, 266, 1, 0, 0, 0, 266, 272, 5, 3, 0, 0, 267, 268, 5, 38, 0, 0, 268, 269, 5, 2, 0, 0, 269, 270, 3, 14, 7, 0, 270, 271, 5, 3, 0, 0, 271, 273, 1, 0, 0, 0, 272, 267, 1, 0, 0, 0, 272, 273, 1, 0, 0, 0, 273, 41, 1, 0, 0, 0, 274, 275, 7, 2, 0, 0, 275, 278, 5, 5, 0, 0, 276, 279, 3, 50, 25, 0, 277, 279, 3, 44, 22, 0, 278, 276, 1, 0, 0, 0, 278, 277, 1, 0, 0, 0, 279, 43, 1, 0, 0, 0, 280, 285, 3, 46, 23, 0, 281, 282, 5, 24, 0, 0, 282, 284, 3, 46, 23, 0, 283, 281, 1, 0, 0, 0, 284, 287, 1, 0, 0, 0, 285, 283, 1, 0, 0, 0, 285, 286, 1, 0, 0, 0, 286, 45, 1, 0, 0, 0, 287, 285, 1, 0, 0, 0, 288, 293, 3, 48, 24, 0, 289, 290, 5, 23, 0, 0, 290, 292, 3, 48, 24, 0, 291, 289, 1, 0, 0, 0, 292, 295, 1, 0, 0, 0, 293, 291, 1, 0, 0, 0, 293, 294, 1, 0, 0, 0, 294, 47, 1, 0, 0, 0, 295, 293, 1, 0, 0, 0, 296, 302, 3, 50, 25, 0, 297, 298, 5, 2, 0, 0, 298, 299, 3, 44, 22, 0, 299, 300, 5, 3, 0, 0, 300, 302, 1, 0, 0, 0, 301, 296, 1, 0, 0, 0, 301, 297, 1, 0, 0, 0, 302, 49, 1, 0, 0, 0, 303, 304, 3, 34, 17, 0, 304, 305, 3, 66, 33, 0, 305, 306, 3, 34, 17, 0, 306, 357, 1, 0, 0, 0, 307, 308, 3, 26, 13, 0, 308, 315, 3, 66, 33, 0, 309, 316, 3, 26, 13, 0, 310, 316, 5, 54, 0, 0, 311, 316, 5, 51, 0, 0, 312, 316, 5, 52, 0, 0, 313, 316, 5, 53, 0, 0, 314, 316, 5, 50, 0, 0, 315, 309, 1, 0, 0, 0, 315, 310, 1, 0, 0, 0, 315, 311, 1, 0, 0, 0, 315, 312, 1, 0, 0, 0, 315, 313, 1, 0, 0, 0, 315, 314, 1, 0, 0, 0, 316, 357, 1, 0, 0, 0, 317, 318, 5, 51, 0, 0, 318, 325, 3, 66, 33, 0, 319, 326, 3, 26, 13, 0, 320, 326, 5, 54, 0, 0, 321, 326, 5, 51, 0, 0, 322, 326, 5, 52, 0, 0, 323, 326, 5, 53, 0, 0, 324, 326, 5, 50, 0, 0, 325, 319, 1, 0, 0, 0, 325, 320, 1, 0, 0, 0, 325, 321, 1, 0, 0, 0, 325, 322, 1, 0, 0, 0, 325, 323, 1, 0, 0, 0, 325, 324, 1, 0, 0, 0, 326, 357, 1, 0, 0, 0, 327, 328, 5, 50, 0, 0, 328, 335, 3, 66, 33, 0, 329, 336, 3, 26, 13, 0, 330, 336, 5, 54, 0, 0, 331, 336, 5, 51, 0, 0, 332, 336, 5, 52, 0, 0, 333, 336, 5, 53, 0, 0, 334, 336, 5, 50, 0, 0, 335, 329, 1, 0, 0, 0, 335, 330, 1, 0, 0, 0, 335, 331, 1, 0, 0, 0, 335, 332, 1, 0, 0, 0, 335, 333, 1, 0, 0, 0, 335, 334, 1, 0, 0, 0, 336, 357, 1, 0, 0, 0, 337, 339, 3, 26, 13, 0, 338, 340, 3, 64, 32, 0, 339, 338, 1, 0, 0, 0, 339, 340, 1, 0, 0, 0, 340, 357, 1, 0, 0, 0, 341, 343, 5, 51, 0, 0, 342, 344, 3, 64, 32, 0, 343, 342, 1, 0, 0, 0, 343, 344, 1, 0, 0, 0, 344, 357, 1, 0, 0, 0, 345, 347, 5, 50, 0, 0, 346, 348, 3, 64, 32, 0, 347, 346, 1, 0, 0, 0, 347, 348, 1, 0, 0, 0, 348, 357, 1, 0, 0, 0, 349, 357, 5, 54, 0, 0, 350, 357, 5, 52, 0, 0, 351, 357, 5, 53, 0, 0, 352, 357, 3, 12, 6, 0, 353, 357, 3, 52, 26, 0, 354, 357, 3, 54, 27, 0, 355, 357, 3, 56, 28, 0, 356, 303, 1, 0, 0, 0, 356, 307, 1, 0, 0, 0, 356, 317, 1, 0, 0, 0, 356, 327, 1, 0, 0, 0, 356, 337, 1, 0, 0, 0, 356, 341, 1, 0, 0, 0, 356, 345, 1, 0, 0, 0, 356, 349, 1, 0, 0, 0, 356, 350, 1, 0, 0, 0, 356, 351, 1, 0, 0, 0, 356, 352, 1, 0, 0, 0, 356, 353, 1, 0, 0, 0, 356, 354, 1, 0, 0, 0, 356, 355, 1, 0, 0, 0, 357, 51, 1, 0, 0, 0, 358, 359, 5, 26, 0, 0, 359, 360, 5, 2, 0, 0, 360, 361, 3, 10, 5, 0, 361, 362, 5, 3, 0, 0, 362, 53, 1, 0, 0, 0, 363, 367, 3, 26, 13, 0, 364, 367, 5, 51, 0, 0, 365, 367, 5, 50, 0, 0, 366, 363, 1, 0, 0, 0, 366, 364, 1, 0, 0, 0, 366, 365, 1, 0, 0, 0, 367, 368, 1, 0, 0, 0, 368, 370, 5, 28, 0, 0, 369, 371, 5, 29, 0, 0, 370, 369, 1, 0, 0, 0, 370, 371, 1, 0, 0, 0, 371, 372, 1, 0, 0, 0, 372, 373, 5, 27, 0, 0, 373, 55, 1, 0, 0, 0, 374, 378, 3, 26, 13, 0, 375, 378, 5, 51, 0, 0, 376, 378, 5, 50, 0, 0, 377, 374, 1, 0, 0, 0, 377, 375, 1, 0, 0, 0, 377, 376, 1, 0, 0, 0, 378, 379, 1, 0, 0, 0, 379, 380, 5, 30, 0, 0, 380, 383, 5, 2, 0, 0, 381, 384, 3, 10, 5, 0, 382, 384, 3, 28, 14, 0, 383, 381, 1, 0, 0, 0, 383, 382, 1, 0, 0, 0, 384, 385, 1, 0, 0, 0, 385, 386, 5, 3, 0, 0, 386, 57, 1, 0, 0, 0, 387, 389, 5, 32, 0, 0, 388, 390, 3, 60, 30, 0, 389, 388, 1, 0, 0, 0, 390, 391, 1, 0, 0, 0, 391, 389, 1, 0, 0, 0, 391, 392, 1, 0, 0, 0, 392, 395, 1, 0, 0, 0, 393, 394, 5, 35, 0, 0, 394, 396, 3, 62, 31, 0, 395, 393, 1, 0, 0, 0, 395, 396, 1, 0, 0, 0, 396, 397, 1, 0, 0, 0, 397, 398, 5, 36, 0, 0, 398, 59, 1, 0, 0, 0, 399, 400, 5, 33, 0, 0, 400, 401, 3, 50, 25, 0, 401, 402, 5, 34, 0, 0, 402, 403, 3, 62, 31, 0, 403, 61, 1, 0, 0, 0, 404, 413, 3, 34, 17, 0, 405, 413, 3, 50, 25, 0, 406, 413, 3, 26, 13, 0, 407, 413, 5, 51, 0, 0, 408, 413, 5, 52, 0, 0, 409, 413, 5, 53, 0, 0, 410, 413, 5, 54, 0, 0, 411, 413, 5, 50, 0, 0, 412, 404, 1, 0, 0, 0, 412, 405, 1, 0, 0, 0, 412, 406, 1, 0, 0, 0, 412, 407, 1, 0, 0, 0, 412, 408, 1, 0, 0, 0, 412, 409, 1, 0, 0, 0, 412, 410, 1, 0, 0, 0, 412, 411, 1, 0, 0, 0, 413, 63, 1, 0, 0, 0, 414, 415, 7, 3, 0, 0, 415, 65, 1, 0, 0, 0, 416, 417, 7, 4, 0, 0, 417, 67, 1, 0, 0, 0, 42, 71, 79, 87, 108, 114, 125, 141, 144, 147, 162, 169, 172, 181, 193, 200, 216, 222, 226, 233, 241, 256, 261, 264, 272, 278, 285, 293, 301, 315, 325, 335, 339, 343, 347, 356, 366, 370, 377, 383, 391, 395, 412] \ No newline at end of file diff --git a/Lql/Nimblesite.Lql.Core/Parsing/Lql.tokens b/Lql/Nimblesite.Lql.Core/Parsing/Lql.tokens index e4a99d28..2c5313b1 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/Lql.tokens +++ b/Lql/Nimblesite.Lql.Core/Parsing/Lql.tokens @@ -55,13 +55,13 @@ STRING=54 COMMENT=55 WS=56 ASTERISK=57 -'let'=1 -'='=2 -'|>'=3 -'('=4 -')'=5 -'fn'=6 -','=7 +','=1 +'('=2 +')'=3 +'let'=4 +'='=5 +'|>'=6 +'fn'=7 '=>'=8 '.'=9 '+'=10 diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs index 031a814e..b766c866 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseListener.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 +// Generated from Lql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -61,6 +61,30 @@ public virtual void EnterStatement([NotNull] LqlParser.StatementContext context) /// The parse tree. public virtual void ExitStatement([NotNull] LqlParser.StatementContext context) { } /// + /// Enter a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void EnterWithStmt([NotNull] LqlParser.WithStmtContext context) { } + /// + /// Exit a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void ExitWithStmt([NotNull] LqlParser.WithStmtContext context) { } + /// + /// Enter a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void EnterCteDef([NotNull] LqlParser.CteDefContext context) { } + /// + /// Exit a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void ExitCteDef([NotNull] LqlParser.CteDefContext context) { } + /// /// Enter a parse tree produced by . /// The default implementation does nothing. /// @@ -133,6 +157,30 @@ public virtual void EnterOrderClause([NotNull] LqlParser.OrderClauseContext cont /// The parse tree. public virtual void ExitOrderClause([NotNull] LqlParser.OrderClauseContext context) { } /// + /// Enter a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void EnterOrderByArgList([NotNull] LqlParser.OrderByArgListContext context) { } + /// + /// Exit a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void ExitOrderByArgList([NotNull] LqlParser.OrderByArgListContext context) { } + /// + /// Enter a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void EnterOrderByArg([NotNull] LqlParser.OrderByArgContext context) { } + /// + /// Exit a parse tree produced by . + /// The default implementation does nothing. + /// + /// The parse tree. + public virtual void ExitOrderByArg([NotNull] LqlParser.OrderByArgContext context) { } + /// /// Enter a parse tree produced by . /// The default implementation does nothing. /// diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs index 24ee7e0f..163c519e 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlBaseVisitor.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 +// Generated from Lql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -56,6 +56,26 @@ public partial class LqlBaseVisitor : AbstractParseTreeVisitor, /// The visitor result. public virtual Result VisitStatement([NotNull] LqlParser.StatementContext context) { return VisitChildren(context); } /// + /// Visit a parse tree produced by . + /// + /// The default implementation returns the result of calling + /// on . + /// + /// + /// The parse tree. + /// The visitor result. + public virtual Result VisitWithStmt([NotNull] LqlParser.WithStmtContext context) { return VisitChildren(context); } + /// + /// Visit a parse tree produced by . + /// + /// The default implementation returns the result of calling + /// on . + /// + /// + /// The parse tree. + /// The visitor result. + public virtual Result VisitCteDef([NotNull] LqlParser.CteDefContext context) { return VisitChildren(context); } + /// /// Visit a parse tree produced by . /// /// The default implementation returns the result of calling @@ -116,6 +136,26 @@ public partial class LqlBaseVisitor : AbstractParseTreeVisitor, /// The visitor result. public virtual Result VisitOrderClause([NotNull] LqlParser.OrderClauseContext context) { return VisitChildren(context); } /// + /// Visit a parse tree produced by . + /// + /// The default implementation returns the result of calling + /// on . + /// + /// + /// The parse tree. + /// The visitor result. + public virtual Result VisitOrderByArgList([NotNull] LqlParser.OrderByArgListContext context) { return VisitChildren(context); } + /// + /// Visit a parse tree produced by . + /// + /// The default implementation returns the result of calling + /// on . + /// + /// + /// The parse tree. + /// The visitor result. + public virtual Result VisitOrderByArg([NotNull] LqlParser.OrderByArgContext context) { return VisitChildren(context); } + /// /// Visit a parse tree produced by . /// /// The default implementation returns the result of calling diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs index 2993e570..48a407c7 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 +// Generated from Lql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -73,7 +73,7 @@ public LqlLexer(ICharStream input, TextWriter output, TextWriter errorOutput) } private static readonly string[] _LiteralNames = { - null, "'let'", "'='", "'|>'", "'('", "')'", "'fn'", "','", "'=>'", "'.'", + null, "','", "'('", "')'", "'let'", "'='", "'|>'", "'fn'", "'=>'", "'.'", "'+'", "'-'", "'||'", "'/'", "'%'", "'!='", "'<>'", "'<'", "'>'", "'<='", "'>='", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, @@ -99,7 +99,7 @@ public override IVocabulary Vocabulary } } - public override string GrammarFileName { get { return "Nimblesite.Lql.Core.g4"; } } + public override string GrammarFileName { get { return "Lql.g4"; } } public override string[] RuleNames { get { return ruleNames; } } @@ -127,8 +127,8 @@ static LqlLexer() { 7,56,2,57,7,57,2,58,7,58,2,59,7,59,2,60,7,60,2,61,7,61,2,62,7,62,2,63, 7,63,2,64,7,64,2,65,7,65,2,66,7,66,2,67,7,67,2,68,7,68,2,69,7,69,2,70, 7,70,2,71,7,71,2,72,7,72,2,73,7,73,2,74,7,74,2,75,7,75,2,76,7,76,2,77, - 7,77,2,78,7,78,2,79,7,79,2,80,7,80,2,81,7,81,2,82,7,82,1,0,1,0,1,0,1,0, - 1,1,1,1,1,2,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,1,6,1,6,1,7,1,7,1,7,1, + 7,77,2,78,7,78,2,79,7,79,2,80,7,80,2,81,7,81,2,82,7,82,1,0,1,0,1,1,1,1, + 1,2,1,2,1,3,1,3,1,3,1,3,1,4,1,4,1,5,1,5,1,5,1,6,1,6,1,6,1,7,1,7,1,7,1, 8,1,8,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,13,1,13,1,14,1,14,1, 14,1,15,1,15,1,15,1,16,1,16,1,17,1,17,1,18,1,18,1,18,1,19,1,19,1,19,1, 20,1,20,1,20,1,20,1,21,1,21,1,21,1,21,1,21,1,22,1,22,1,22,1,22,1,23,1, @@ -177,8 +177,8 @@ static LqlLexer() { 1,0,0,0,0,77,1,0,0,0,0,79,1,0,0,0,0,81,1,0,0,0,0,83,1,0,0,0,0,85,1,0,0, 0,0,87,1,0,0,0,0,89,1,0,0,0,0,91,1,0,0,0,0,93,1,0,0,0,0,95,1,0,0,0,0,97, 1,0,0,0,0,151,1,0,0,0,0,153,1,0,0,0,0,155,1,0,0,0,0,157,1,0,0,0,0,159, - 1,0,0,0,0,161,1,0,0,0,0,163,1,0,0,0,0,165,1,0,0,0,1,167,1,0,0,0,3,171, - 1,0,0,0,5,173,1,0,0,0,7,176,1,0,0,0,9,178,1,0,0,0,11,180,1,0,0,0,13,183, + 1,0,0,0,0,161,1,0,0,0,0,163,1,0,0,0,0,165,1,0,0,0,1,167,1,0,0,0,3,169, + 1,0,0,0,5,171,1,0,0,0,7,173,1,0,0,0,9,177,1,0,0,0,11,179,1,0,0,0,13,182, 1,0,0,0,15,185,1,0,0,0,17,188,1,0,0,0,19,190,1,0,0,0,21,192,1,0,0,0,23, 194,1,0,0,0,25,197,1,0,0,0,27,199,1,0,0,0,29,201,1,0,0,0,31,204,1,0,0, 0,33,207,1,0,0,0,35,209,1,0,0,0,37,211,1,0,0,0,39,214,1,0,0,0,41,217,1, @@ -195,18 +195,18 @@ static LqlLexer() { 0,135,419,1,0,0,0,137,421,1,0,0,0,139,423,1,0,0,0,141,425,1,0,0,0,143, 427,1,0,0,0,145,429,1,0,0,0,147,431,1,0,0,0,149,433,1,0,0,0,151,435,1, 0,0,0,153,443,1,0,0,0,155,451,1,0,0,0,157,456,1,0,0,0,159,466,1,0,0,0, - 161,477,1,0,0,0,163,489,1,0,0,0,165,495,1,0,0,0,167,168,5,108,0,0,168, - 169,5,101,0,0,169,170,5,116,0,0,170,2,1,0,0,0,171,172,5,61,0,0,172,4,1, - 0,0,0,173,174,5,124,0,0,174,175,5,62,0,0,175,6,1,0,0,0,176,177,5,40,0, - 0,177,8,1,0,0,0,178,179,5,41,0,0,179,10,1,0,0,0,180,181,5,102,0,0,181, - 182,5,110,0,0,182,12,1,0,0,0,183,184,5,44,0,0,184,14,1,0,0,0,185,186,5, - 61,0,0,186,187,5,62,0,0,187,16,1,0,0,0,188,189,5,46,0,0,189,18,1,0,0,0, - 190,191,5,43,0,0,191,20,1,0,0,0,192,193,5,45,0,0,193,22,1,0,0,0,194,195, - 5,124,0,0,195,196,5,124,0,0,196,24,1,0,0,0,197,198,5,47,0,0,198,26,1,0, - 0,0,199,200,5,37,0,0,200,28,1,0,0,0,201,202,5,33,0,0,202,203,5,61,0,0, - 203,30,1,0,0,0,204,205,5,60,0,0,205,206,5,62,0,0,206,32,1,0,0,0,207,208, - 5,60,0,0,208,34,1,0,0,0,209,210,5,62,0,0,210,36,1,0,0,0,211,212,5,60,0, - 0,212,213,5,61,0,0,213,38,1,0,0,0,214,215,5,62,0,0,215,216,5,61,0,0,216, + 161,477,1,0,0,0,163,489,1,0,0,0,165,495,1,0,0,0,167,168,5,44,0,0,168,2, + 1,0,0,0,169,170,5,40,0,0,170,4,1,0,0,0,171,172,5,41,0,0,172,6,1,0,0,0, + 173,174,5,108,0,0,174,175,5,101,0,0,175,176,5,116,0,0,176,8,1,0,0,0,177, + 178,5,61,0,0,178,10,1,0,0,0,179,180,5,124,0,0,180,181,5,62,0,0,181,12, + 1,0,0,0,182,183,5,102,0,0,183,184,5,110,0,0,184,14,1,0,0,0,185,186,5,61, + 0,0,186,187,5,62,0,0,187,16,1,0,0,0,188,189,5,46,0,0,189,18,1,0,0,0,190, + 191,5,43,0,0,191,20,1,0,0,0,192,193,5,45,0,0,193,22,1,0,0,0,194,195,5, + 124,0,0,195,196,5,124,0,0,196,24,1,0,0,0,197,198,5,47,0,0,198,26,1,0,0, + 0,199,200,5,37,0,0,200,28,1,0,0,0,201,202,5,33,0,0,202,203,5,61,0,0,203, + 30,1,0,0,0,204,205,5,60,0,0,205,206,5,62,0,0,206,32,1,0,0,0,207,208,5, + 60,0,0,208,34,1,0,0,0,209,210,5,62,0,0,210,36,1,0,0,0,211,212,5,60,0,0, + 212,213,5,61,0,0,213,38,1,0,0,0,214,215,5,62,0,0,215,216,5,61,0,0,216, 40,1,0,0,0,217,218,3,99,49,0,218,219,3,135,67,0,219,220,3,103,51,0,220, 42,1,0,0,0,221,222,3,105,52,0,222,223,3,107,53,0,223,224,3,135,67,0,224, 225,3,103,51,0,225,44,1,0,0,0,226,227,3,99,49,0,227,228,3,125,62,0,228, diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.interp b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.interp index 91523dfb..e75ab19e 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.interp +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.interp @@ -1,12 +1,12 @@ token literal names: null +',' +'(' +')' 'let' '=' '|>' -'(' -')' 'fn' -',' '=>' '.' '+' @@ -211,4 +211,4 @@ mode names: DEFAULT_MODE atn: -[4, 0, 57, 497, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2, 31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36, 7, 36, 2, 37, 7, 37, 2, 38, 7, 38, 2, 39, 7, 39, 2, 40, 7, 40, 2, 41, 7, 41, 2, 42, 7, 42, 2, 43, 7, 43, 2, 44, 7, 44, 2, 45, 7, 45, 2, 46, 7, 46, 2, 47, 7, 47, 2, 48, 7, 48, 2, 49, 7, 49, 2, 50, 7, 50, 2, 51, 7, 51, 2, 52, 7, 52, 2, 53, 7, 53, 2, 54, 7, 54, 2, 55, 7, 55, 2, 56, 7, 56, 2, 57, 7, 57, 2, 58, 7, 58, 2, 59, 7, 59, 2, 60, 7, 60, 2, 61, 7, 61, 2, 62, 7, 62, 2, 63, 7, 63, 2, 64, 7, 64, 2, 65, 7, 65, 2, 66, 7, 66, 2, 67, 7, 67, 2, 68, 7, 68, 2, 69, 7, 69, 2, 70, 7, 70, 2, 71, 7, 71, 2, 72, 7, 72, 2, 73, 7, 73, 2, 74, 7, 74, 2, 75, 7, 75, 2, 76, 7, 76, 2, 77, 7, 77, 2, 78, 7, 78, 2, 79, 7, 79, 2, 80, 7, 80, 2, 81, 7, 81, 2, 82, 7, 82, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11, 1, 12, 1, 12, 1, 13, 1, 13, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 1, 16, 1, 16, 1, 17, 1, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1, 20, 1, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 22, 1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 27, 1, 27, 1, 27, 1, 28, 1, 28, 1, 28, 1, 28, 1, 29, 1, 29, 1, 29, 1, 30, 1, 30, 1, 30, 1, 31, 1, 31, 1, 31, 1, 31, 1, 31, 1, 32, 1, 32, 1, 32, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 1, 33, 1, 33, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 35, 1, 35, 1, 35, 1, 35, 1, 36, 1, 36, 1, 36, 1, 36, 1, 36, 1, 37, 1, 37, 1, 37, 1, 37, 1, 37, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 39, 1, 39, 1, 39, 1, 39, 1, 39, 1, 39, 1, 40, 1, 40, 1, 40, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 43, 1, 43, 1, 43, 1, 43, 1, 43, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 47, 1, 47, 1, 47, 1, 48, 1, 48, 1, 48, 1, 48, 1, 48, 1, 49, 1, 49, 1, 50, 1, 50, 1, 51, 1, 51, 1, 52, 1, 52, 1, 53, 1, 53, 1, 54, 1, 54, 1, 55, 1, 55, 1, 56, 1, 56, 1, 57, 1, 57, 1, 58, 1, 58, 1, 59, 1, 59, 1, 60, 1, 60, 1, 61, 1, 61, 1, 62, 1, 62, 1, 63, 1, 63, 1, 64, 1, 64, 1, 65, 1, 65, 1, 66, 1, 66, 1, 67, 1, 67, 1, 68, 1, 68, 1, 69, 1, 69, 1, 70, 1, 70, 1, 71, 1, 71, 1, 72, 1, 72, 1, 73, 1, 73, 1, 74, 1, 74, 1, 75, 1, 75, 1, 75, 5, 75, 439, 8, 75, 10, 75, 12, 75, 442, 9, 75, 1, 76, 1, 76, 5, 76, 446, 8, 76, 10, 76, 12, 76, 449, 9, 76, 1, 77, 4, 77, 452, 8, 77, 11, 77, 12, 77, 453, 1, 78, 4, 78, 457, 8, 78, 11, 78, 12, 78, 458, 1, 78, 1, 78, 4, 78, 463, 8, 78, 11, 78, 12, 78, 464, 1, 79, 1, 79, 1, 79, 1, 79, 5, 79, 471, 8, 79, 10, 79, 12, 79, 474, 9, 79, 1, 79, 1, 79, 1, 80, 1, 80, 1, 80, 1, 80, 5, 80, 482, 8, 80, 10, 80, 12, 80, 485, 9, 80, 1, 80, 1, 80, 1, 81, 4, 81, 490, 8, 81, 11, 81, 12, 81, 491, 1, 81, 1, 81, 1, 82, 1, 82, 0, 0, 83, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12, 25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37, 19, 39, 20, 41, 21, 43, 22, 45, 23, 47, 24, 49, 25, 51, 26, 53, 27, 55, 28, 57, 29, 59, 30, 61, 31, 63, 32, 65, 33, 67, 34, 69, 35, 71, 36, 73, 37, 75, 38, 77, 39, 79, 40, 81, 41, 83, 42, 85, 43, 87, 44, 89, 45, 91, 46, 93, 47, 95, 48, 97, 49, 99, 0, 101, 0, 103, 0, 105, 0, 107, 0, 109, 0, 111, 0, 113, 0, 115, 0, 117, 0, 119, 0, 121, 0, 123, 0, 125, 0, 127, 0, 129, 0, 131, 0, 133, 0, 135, 0, 137, 0, 139, 0, 141, 0, 143, 0, 145, 0, 147, 0, 149, 0, 151, 50, 153, 51, 155, 52, 157, 53, 159, 54, 161, 55, 163, 56, 165, 57, 1, 0, 32, 2, 0, 65, 65, 97, 97, 2, 0, 66, 66, 98, 98, 2, 0, 67, 67, 99, 99, 2, 0, 68, 68, 100, 100, 2, 0, 69, 69, 101, 101, 2, 0, 70, 70, 102, 102, 2, 0, 71, 71, 103, 103, 2, 0, 72, 72, 104, 104, 2, 0, 73, 73, 105, 105, 2, 0, 74, 74, 106, 106, 2, 0, 75, 75, 107, 107, 2, 0, 76, 76, 108, 108, 2, 0, 77, 77, 109, 109, 2, 0, 78, 78, 110, 110, 2, 0, 79, 79, 111, 111, 2, 0, 80, 80, 112, 112, 2, 0, 81, 81, 113, 113, 2, 0, 82, 82, 114, 114, 2, 0, 83, 83, 115, 115, 2, 0, 84, 84, 116, 116, 2, 0, 85, 85, 117, 117, 2, 0, 86, 86, 118, 118, 2, 0, 87, 87, 119, 119, 2, 0, 88, 88, 120, 120, 2, 0, 89, 89, 121, 121, 2, 0, 90, 90, 122, 122, 3, 0, 65, 90, 95, 95, 97, 122, 4, 0, 48, 57, 65, 90, 95, 95, 97, 122, 1, 0, 48, 57, 2, 0, 39, 39, 92, 92, 2, 0, 10, 10, 13, 13, 3, 0, 9, 10, 13, 13, 32, 32, 479, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0, 47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0, 0, 55, 1, 0, 0, 0, 0, 57, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 61, 1, 0, 0, 0, 0, 63, 1, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 67, 1, 0, 0, 0, 0, 69, 1, 0, 0, 0, 0, 71, 1, 0, 0, 0, 0, 73, 1, 0, 0, 0, 0, 75, 1, 0, 0, 0, 0, 77, 1, 0, 0, 0, 0, 79, 1, 0, 0, 0, 0, 81, 1, 0, 0, 0, 0, 83, 1, 0, 0, 0, 0, 85, 1, 0, 0, 0, 0, 87, 1, 0, 0, 0, 0, 89, 1, 0, 0, 0, 0, 91, 1, 0, 0, 0, 0, 93, 1, 0, 0, 0, 0, 95, 1, 0, 0, 0, 0, 97, 1, 0, 0, 0, 0, 151, 1, 0, 0, 0, 0, 153, 1, 0, 0, 0, 0, 155, 1, 0, 0, 0, 0, 157, 1, 0, 0, 0, 0, 159, 1, 0, 0, 0, 0, 161, 1, 0, 0, 0, 0, 163, 1, 0, 0, 0, 0, 165, 1, 0, 0, 0, 1, 167, 1, 0, 0, 0, 3, 171, 1, 0, 0, 0, 5, 173, 1, 0, 0, 0, 7, 176, 1, 0, 0, 0, 9, 178, 1, 0, 0, 0, 11, 180, 1, 0, 0, 0, 13, 183, 1, 0, 0, 0, 15, 185, 1, 0, 0, 0, 17, 188, 1, 0, 0, 0, 19, 190, 1, 0, 0, 0, 21, 192, 1, 0, 0, 0, 23, 194, 1, 0, 0, 0, 25, 197, 1, 0, 0, 0, 27, 199, 1, 0, 0, 0, 29, 201, 1, 0, 0, 0, 31, 204, 1, 0, 0, 0, 33, 207, 1, 0, 0, 0, 35, 209, 1, 0, 0, 0, 37, 211, 1, 0, 0, 0, 39, 214, 1, 0, 0, 0, 41, 217, 1, 0, 0, 0, 43, 221, 1, 0, 0, 0, 45, 226, 1, 0, 0, 0, 47, 230, 1, 0, 0, 0, 49, 233, 1, 0, 0, 0, 51, 242, 1, 0, 0, 0, 53, 249, 1, 0, 0, 0, 55, 254, 1, 0, 0, 0, 57, 257, 1, 0, 0, 0, 59, 261, 1, 0, 0, 0, 61, 264, 1, 0, 0, 0, 63, 267, 1, 0, 0, 0, 65, 272, 1, 0, 0, 0, 67, 277, 1, 0, 0, 0, 69, 282, 1, 0, 0, 0, 71, 287, 1, 0, 0, 0, 73, 291, 1, 0, 0, 0, 75, 296, 1, 0, 0, 0, 77, 301, 1, 0, 0, 0, 79, 311, 1, 0, 0, 0, 81, 317, 1, 0, 0, 0, 83, 320, 1, 0, 0, 0, 85, 329, 1, 0, 0, 0, 87, 337, 1, 0, 0, 0, 89, 342, 1, 0, 0, 0, 91, 351, 1, 0, 0, 0, 93, 364, 1, 0, 0, 0, 95, 375, 1, 0, 0, 0, 97, 378, 1, 0, 0, 0, 99, 383, 1, 0, 0, 0, 101, 385, 1, 0, 0, 0, 103, 387, 1, 0, 0, 0, 105, 389, 1, 0, 0, 0, 107, 391, 1, 0, 0, 0, 109, 393, 1, 0, 0, 0, 111, 395, 1, 0, 0, 0, 113, 397, 1, 0, 0, 0, 115, 399, 1, 0, 0, 0, 117, 401, 1, 0, 0, 0, 119, 403, 1, 0, 0, 0, 121, 405, 1, 0, 0, 0, 123, 407, 1, 0, 0, 0, 125, 409, 1, 0, 0, 0, 127, 411, 1, 0, 0, 0, 129, 413, 1, 0, 0, 0, 131, 415, 1, 0, 0, 0, 133, 417, 1, 0, 0, 0, 135, 419, 1, 0, 0, 0, 137, 421, 1, 0, 0, 0, 139, 423, 1, 0, 0, 0, 141, 425, 1, 0, 0, 0, 143, 427, 1, 0, 0, 0, 145, 429, 1, 0, 0, 0, 147, 431, 1, 0, 0, 0, 149, 433, 1, 0, 0, 0, 151, 435, 1, 0, 0, 0, 153, 443, 1, 0, 0, 0, 155, 451, 1, 0, 0, 0, 157, 456, 1, 0, 0, 0, 159, 466, 1, 0, 0, 0, 161, 477, 1, 0, 0, 0, 163, 489, 1, 0, 0, 0, 165, 495, 1, 0, 0, 0, 167, 168, 5, 108, 0, 0, 168, 169, 5, 101, 0, 0, 169, 170, 5, 116, 0, 0, 170, 2, 1, 0, 0, 0, 171, 172, 5, 61, 0, 0, 172, 4, 1, 0, 0, 0, 173, 174, 5, 124, 0, 0, 174, 175, 5, 62, 0, 0, 175, 6, 1, 0, 0, 0, 176, 177, 5, 40, 0, 0, 177, 8, 1, 0, 0, 0, 178, 179, 5, 41, 0, 0, 179, 10, 1, 0, 0, 0, 180, 181, 5, 102, 0, 0, 181, 182, 5, 110, 0, 0, 182, 12, 1, 0, 0, 0, 183, 184, 5, 44, 0, 0, 184, 14, 1, 0, 0, 0, 185, 186, 5, 61, 0, 0, 186, 187, 5, 62, 0, 0, 187, 16, 1, 0, 0, 0, 188, 189, 5, 46, 0, 0, 189, 18, 1, 0, 0, 0, 190, 191, 5, 43, 0, 0, 191, 20, 1, 0, 0, 0, 192, 193, 5, 45, 0, 0, 193, 22, 1, 0, 0, 0, 194, 195, 5, 124, 0, 0, 195, 196, 5, 124, 0, 0, 196, 24, 1, 0, 0, 0, 197, 198, 5, 47, 0, 0, 198, 26, 1, 0, 0, 0, 199, 200, 5, 37, 0, 0, 200, 28, 1, 0, 0, 0, 201, 202, 5, 33, 0, 0, 202, 203, 5, 61, 0, 0, 203, 30, 1, 0, 0, 0, 204, 205, 5, 60, 0, 0, 205, 206, 5, 62, 0, 0, 206, 32, 1, 0, 0, 0, 207, 208, 5, 60, 0, 0, 208, 34, 1, 0, 0, 0, 209, 210, 5, 62, 0, 0, 210, 36, 1, 0, 0, 0, 211, 212, 5, 60, 0, 0, 212, 213, 5, 61, 0, 0, 213, 38, 1, 0, 0, 0, 214, 215, 5, 62, 0, 0, 215, 216, 5, 61, 0, 0, 216, 40, 1, 0, 0, 0, 217, 218, 3, 99, 49, 0, 218, 219, 3, 135, 67, 0, 219, 220, 3, 103, 51, 0, 220, 42, 1, 0, 0, 0, 221, 222, 3, 105, 52, 0, 222, 223, 3, 107, 53, 0, 223, 224, 3, 135, 67, 0, 224, 225, 3, 103, 51, 0, 225, 44, 1, 0, 0, 0, 226, 227, 3, 99, 49, 0, 227, 228, 3, 125, 62, 0, 228, 229, 3, 105, 52, 0, 229, 46, 1, 0, 0, 0, 230, 231, 3, 127, 63, 0, 231, 232, 3, 133, 66, 0, 232, 48, 1, 0, 0, 0, 233, 234, 3, 105, 52, 0, 234, 235, 3, 115, 57, 0, 235, 236, 3, 135, 67, 0, 236, 237, 3, 137, 68, 0, 237, 238, 3, 115, 57, 0, 238, 239, 3, 125, 62, 0, 239, 240, 3, 103, 51, 0, 240, 241, 3, 137, 68, 0, 241, 50, 1, 0, 0, 0, 242, 243, 3, 107, 53, 0, 243, 244, 3, 145, 72, 0, 244, 245, 3, 115, 57, 0, 245, 246, 3, 135, 67, 0, 246, 247, 3, 137, 68, 0, 247, 248, 3, 135, 67, 0, 248, 52, 1, 0, 0, 0, 249, 250, 3, 125, 62, 0, 250, 251, 3, 139, 69, 0, 251, 252, 3, 121, 60, 0, 252, 253, 3, 121, 60, 0, 253, 54, 1, 0, 0, 0, 254, 255, 3, 115, 57, 0, 255, 256, 3, 135, 67, 0, 256, 56, 1, 0, 0, 0, 257, 258, 3, 125, 62, 0, 258, 259, 3, 127, 63, 0, 259, 260, 3, 137, 68, 0, 260, 58, 1, 0, 0, 0, 261, 262, 3, 115, 57, 0, 262, 263, 3, 125, 62, 0, 263, 60, 1, 0, 0, 0, 264, 265, 3, 99, 49, 0, 265, 266, 3, 135, 67, 0, 266, 62, 1, 0, 0, 0, 267, 268, 3, 103, 51, 0, 268, 269, 3, 99, 49, 0, 269, 270, 3, 135, 67, 0, 270, 271, 3, 107, 53, 0, 271, 64, 1, 0, 0, 0, 272, 273, 3, 143, 71, 0, 273, 274, 3, 113, 56, 0, 274, 275, 3, 107, 53, 0, 275, 276, 3, 125, 62, 0, 276, 66, 1, 0, 0, 0, 277, 278, 3, 137, 68, 0, 278, 279, 3, 113, 56, 0, 279, 280, 3, 107, 53, 0, 280, 281, 3, 125, 62, 0, 281, 68, 1, 0, 0, 0, 282, 283, 3, 107, 53, 0, 283, 284, 3, 121, 60, 0, 284, 285, 3, 135, 67, 0, 285, 286, 3, 107, 53, 0, 286, 70, 1, 0, 0, 0, 287, 288, 3, 107, 53, 0, 288, 289, 3, 125, 62, 0, 289, 290, 3, 105, 52, 0, 290, 72, 1, 0, 0, 0, 291, 292, 3, 143, 71, 0, 292, 293, 3, 115, 57, 0, 293, 294, 3, 137, 68, 0, 294, 295, 3, 113, 56, 0, 295, 74, 1, 0, 0, 0, 296, 297, 3, 127, 63, 0, 297, 298, 3, 141, 70, 0, 298, 299, 3, 107, 53, 0, 299, 300, 3, 133, 66, 0, 300, 76, 1, 0, 0, 0, 301, 302, 3, 129, 64, 0, 302, 303, 3, 99, 49, 0, 303, 304, 3, 133, 66, 0, 304, 305, 3, 137, 68, 0, 305, 306, 3, 115, 57, 0, 306, 307, 3, 137, 68, 0, 307, 308, 3, 115, 57, 0, 308, 309, 3, 127, 63, 0, 309, 310, 3, 125, 62, 0, 310, 78, 1, 0, 0, 0, 311, 312, 3, 127, 63, 0, 312, 313, 3, 133, 66, 0, 313, 314, 3, 105, 52, 0, 314, 315, 3, 107, 53, 0, 315, 316, 3, 133, 66, 0, 316, 80, 1, 0, 0, 0, 317, 318, 3, 101, 50, 0, 318, 319, 3, 147, 73, 0, 319, 82, 1, 0, 0, 0, 320, 321, 3, 103, 51, 0, 321, 322, 3, 127, 63, 0, 322, 323, 3, 99, 49, 0, 323, 324, 3, 121, 60, 0, 324, 325, 3, 107, 53, 0, 325, 326, 3, 135, 67, 0, 326, 327, 3, 103, 51, 0, 327, 328, 3, 107, 53, 0, 328, 84, 1, 0, 0, 0, 329, 330, 3, 107, 53, 0, 330, 331, 3, 145, 72, 0, 331, 332, 3, 137, 68, 0, 332, 333, 3, 133, 66, 0, 333, 334, 3, 99, 49, 0, 334, 335, 3, 103, 51, 0, 335, 336, 3, 137, 68, 0, 336, 86, 1, 0, 0, 0, 337, 338, 3, 109, 54, 0, 338, 339, 3, 133, 66, 0, 339, 340, 3, 127, 63, 0, 340, 341, 3, 123, 61, 0, 341, 88, 1, 0, 0, 0, 342, 343, 3, 115, 57, 0, 343, 344, 3, 125, 62, 0, 344, 345, 3, 137, 68, 0, 345, 346, 3, 107, 53, 0, 346, 347, 3, 133, 66, 0, 347, 348, 3, 141, 70, 0, 348, 349, 3, 99, 49, 0, 349, 350, 3, 121, 60, 0, 350, 90, 1, 0, 0, 0, 351, 352, 3, 103, 51, 0, 352, 353, 3, 139, 69, 0, 353, 354, 3, 133, 66, 0, 354, 355, 3, 133, 66, 0, 355, 356, 3, 107, 53, 0, 356, 357, 3, 125, 62, 0, 357, 358, 3, 137, 68, 0, 358, 359, 5, 95, 0, 0, 359, 360, 3, 105, 52, 0, 360, 361, 3, 99, 49, 0, 361, 362, 3, 137, 68, 0, 362, 363, 3, 107, 53, 0, 363, 92, 1, 0, 0, 0, 364, 365, 3, 105, 52, 0, 365, 366, 3, 99, 49, 0, 366, 367, 3, 137, 68, 0, 367, 368, 3, 107, 53, 0, 368, 369, 5, 95, 0, 0, 369, 370, 3, 137, 68, 0, 370, 371, 3, 133, 66, 0, 371, 372, 3, 139, 69, 0, 372, 373, 3, 125, 62, 0, 373, 374, 3, 103, 51, 0, 374, 94, 1, 0, 0, 0, 375, 376, 3, 127, 63, 0, 376, 377, 3, 125, 62, 0, 377, 96, 1, 0, 0, 0, 378, 379, 3, 121, 60, 0, 379, 380, 3, 115, 57, 0, 380, 381, 3, 119, 59, 0, 381, 382, 3, 107, 53, 0, 382, 98, 1, 0, 0, 0, 383, 384, 7, 0, 0, 0, 384, 100, 1, 0, 0, 0, 385, 386, 7, 1, 0, 0, 386, 102, 1, 0, 0, 0, 387, 388, 7, 2, 0, 0, 388, 104, 1, 0, 0, 0, 389, 390, 7, 3, 0, 0, 390, 106, 1, 0, 0, 0, 391, 392, 7, 4, 0, 0, 392, 108, 1, 0, 0, 0, 393, 394, 7, 5, 0, 0, 394, 110, 1, 0, 0, 0, 395, 396, 7, 6, 0, 0, 396, 112, 1, 0, 0, 0, 397, 398, 7, 7, 0, 0, 398, 114, 1, 0, 0, 0, 399, 400, 7, 8, 0, 0, 400, 116, 1, 0, 0, 0, 401, 402, 7, 9, 0, 0, 402, 118, 1, 0, 0, 0, 403, 404, 7, 10, 0, 0, 404, 120, 1, 0, 0, 0, 405, 406, 7, 11, 0, 0, 406, 122, 1, 0, 0, 0, 407, 408, 7, 12, 0, 0, 408, 124, 1, 0, 0, 0, 409, 410, 7, 13, 0, 0, 410, 126, 1, 0, 0, 0, 411, 412, 7, 14, 0, 0, 412, 128, 1, 0, 0, 0, 413, 414, 7, 15, 0, 0, 414, 130, 1, 0, 0, 0, 415, 416, 7, 16, 0, 0, 416, 132, 1, 0, 0, 0, 417, 418, 7, 17, 0, 0, 418, 134, 1, 0, 0, 0, 419, 420, 7, 18, 0, 0, 420, 136, 1, 0, 0, 0, 421, 422, 7, 19, 0, 0, 422, 138, 1, 0, 0, 0, 423, 424, 7, 20, 0, 0, 424, 140, 1, 0, 0, 0, 425, 426, 7, 21, 0, 0, 426, 142, 1, 0, 0, 0, 427, 428, 7, 22, 0, 0, 428, 144, 1, 0, 0, 0, 429, 430, 7, 23, 0, 0, 430, 146, 1, 0, 0, 0, 431, 432, 7, 24, 0, 0, 432, 148, 1, 0, 0, 0, 433, 434, 7, 25, 0, 0, 434, 150, 1, 0, 0, 0, 435, 436, 5, 64, 0, 0, 436, 440, 7, 26, 0, 0, 437, 439, 7, 27, 0, 0, 438, 437, 1, 0, 0, 0, 439, 442, 1, 0, 0, 0, 440, 438, 1, 0, 0, 0, 440, 441, 1, 0, 0, 0, 441, 152, 1, 0, 0, 0, 442, 440, 1, 0, 0, 0, 443, 447, 7, 26, 0, 0, 444, 446, 7, 27, 0, 0, 445, 444, 1, 0, 0, 0, 446, 449, 1, 0, 0, 0, 447, 445, 1, 0, 0, 0, 447, 448, 1, 0, 0, 0, 448, 154, 1, 0, 0, 0, 449, 447, 1, 0, 0, 0, 450, 452, 7, 28, 0, 0, 451, 450, 1, 0, 0, 0, 452, 453, 1, 0, 0, 0, 453, 451, 1, 0, 0, 0, 453, 454, 1, 0, 0, 0, 454, 156, 1, 0, 0, 0, 455, 457, 7, 28, 0, 0, 456, 455, 1, 0, 0, 0, 457, 458, 1, 0, 0, 0, 458, 456, 1, 0, 0, 0, 458, 459, 1, 0, 0, 0, 459, 460, 1, 0, 0, 0, 460, 462, 5, 46, 0, 0, 461, 463, 7, 28, 0, 0, 462, 461, 1, 0, 0, 0, 463, 464, 1, 0, 0, 0, 464, 462, 1, 0, 0, 0, 464, 465, 1, 0, 0, 0, 465, 158, 1, 0, 0, 0, 466, 472, 5, 39, 0, 0, 467, 471, 8, 29, 0, 0, 468, 469, 5, 92, 0, 0, 469, 471, 9, 0, 0, 0, 470, 467, 1, 0, 0, 0, 470, 468, 1, 0, 0, 0, 471, 474, 1, 0, 0, 0, 472, 470, 1, 0, 0, 0, 472, 473, 1, 0, 0, 0, 473, 475, 1, 0, 0, 0, 474, 472, 1, 0, 0, 0, 475, 476, 5, 39, 0, 0, 476, 160, 1, 0, 0, 0, 477, 478, 5, 45, 0, 0, 478, 479, 5, 45, 0, 0, 479, 483, 1, 0, 0, 0, 480, 482, 8, 30, 0, 0, 481, 480, 1, 0, 0, 0, 482, 485, 1, 0, 0, 0, 483, 481, 1, 0, 0, 0, 483, 484, 1, 0, 0, 0, 484, 486, 1, 0, 0, 0, 485, 483, 1, 0, 0, 0, 486, 487, 6, 80, 0, 0, 487, 162, 1, 0, 0, 0, 488, 490, 7, 31, 0, 0, 489, 488, 1, 0, 0, 0, 490, 491, 1, 0, 0, 0, 491, 489, 1, 0, 0, 0, 491, 492, 1, 0, 0, 0, 492, 493, 1, 0, 0, 0, 493, 494, 6, 81, 0, 0, 494, 164, 1, 0, 0, 0, 495, 496, 5, 42, 0, 0, 496, 166, 1, 0, 0, 0, 10, 0, 440, 447, 453, 458, 464, 470, 472, 483, 491, 1, 6, 0, 0] \ No newline at end of file +[4, 0, 57, 497, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2, 10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15, 7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7, 20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25, 2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2, 31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36, 7, 36, 2, 37, 7, 37, 2, 38, 7, 38, 2, 39, 7, 39, 2, 40, 7, 40, 2, 41, 7, 41, 2, 42, 7, 42, 2, 43, 7, 43, 2, 44, 7, 44, 2, 45, 7, 45, 2, 46, 7, 46, 2, 47, 7, 47, 2, 48, 7, 48, 2, 49, 7, 49, 2, 50, 7, 50, 2, 51, 7, 51, 2, 52, 7, 52, 2, 53, 7, 53, 2, 54, 7, 54, 2, 55, 7, 55, 2, 56, 7, 56, 2, 57, 7, 57, 2, 58, 7, 58, 2, 59, 7, 59, 2, 60, 7, 60, 2, 61, 7, 61, 2, 62, 7, 62, 2, 63, 7, 63, 2, 64, 7, 64, 2, 65, 7, 65, 2, 66, 7, 66, 2, 67, 7, 67, 2, 68, 7, 68, 2, 69, 7, 69, 2, 70, 7, 70, 2, 71, 7, 71, 2, 72, 7, 72, 2, 73, 7, 73, 2, 74, 7, 74, 2, 75, 7, 75, 2, 76, 7, 76, 2, 77, 7, 77, 2, 78, 7, 78, 2, 79, 7, 79, 2, 80, 7, 80, 2, 81, 7, 81, 2, 82, 7, 82, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11, 1, 12, 1, 12, 1, 13, 1, 13, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 1, 16, 1, 16, 1, 17, 1, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1, 20, 1, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 21, 1, 21, 1, 22, 1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 27, 1, 27, 1, 27, 1, 28, 1, 28, 1, 28, 1, 28, 1, 29, 1, 29, 1, 29, 1, 30, 1, 30, 1, 30, 1, 31, 1, 31, 1, 31, 1, 31, 1, 31, 1, 32, 1, 32, 1, 32, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 1, 33, 1, 33, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1, 35, 1, 35, 1, 35, 1, 35, 1, 36, 1, 36, 1, 36, 1, 36, 1, 36, 1, 37, 1, 37, 1, 37, 1, 37, 1, 37, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 38, 1, 39, 1, 39, 1, 39, 1, 39, 1, 39, 1, 39, 1, 40, 1, 40, 1, 40, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 41, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 42, 1, 43, 1, 43, 1, 43, 1, 43, 1, 43, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 44, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 45, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 46, 1, 47, 1, 47, 1, 47, 1, 48, 1, 48, 1, 48, 1, 48, 1, 48, 1, 49, 1, 49, 1, 50, 1, 50, 1, 51, 1, 51, 1, 52, 1, 52, 1, 53, 1, 53, 1, 54, 1, 54, 1, 55, 1, 55, 1, 56, 1, 56, 1, 57, 1, 57, 1, 58, 1, 58, 1, 59, 1, 59, 1, 60, 1, 60, 1, 61, 1, 61, 1, 62, 1, 62, 1, 63, 1, 63, 1, 64, 1, 64, 1, 65, 1, 65, 1, 66, 1, 66, 1, 67, 1, 67, 1, 68, 1, 68, 1, 69, 1, 69, 1, 70, 1, 70, 1, 71, 1, 71, 1, 72, 1, 72, 1, 73, 1, 73, 1, 74, 1, 74, 1, 75, 1, 75, 1, 75, 5, 75, 439, 8, 75, 10, 75, 12, 75, 442, 9, 75, 1, 76, 1, 76, 5, 76, 446, 8, 76, 10, 76, 12, 76, 449, 9, 76, 1, 77, 4, 77, 452, 8, 77, 11, 77, 12, 77, 453, 1, 78, 4, 78, 457, 8, 78, 11, 78, 12, 78, 458, 1, 78, 1, 78, 4, 78, 463, 8, 78, 11, 78, 12, 78, 464, 1, 79, 1, 79, 1, 79, 1, 79, 5, 79, 471, 8, 79, 10, 79, 12, 79, 474, 9, 79, 1, 79, 1, 79, 1, 80, 1, 80, 1, 80, 1, 80, 5, 80, 482, 8, 80, 10, 80, 12, 80, 485, 9, 80, 1, 80, 1, 80, 1, 81, 4, 81, 490, 8, 81, 11, 81, 12, 81, 491, 1, 81, 1, 81, 1, 82, 1, 82, 0, 0, 83, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19, 10, 21, 11, 23, 12, 25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37, 19, 39, 20, 41, 21, 43, 22, 45, 23, 47, 24, 49, 25, 51, 26, 53, 27, 55, 28, 57, 29, 59, 30, 61, 31, 63, 32, 65, 33, 67, 34, 69, 35, 71, 36, 73, 37, 75, 38, 77, 39, 79, 40, 81, 41, 83, 42, 85, 43, 87, 44, 89, 45, 91, 46, 93, 47, 95, 48, 97, 49, 99, 0, 101, 0, 103, 0, 105, 0, 107, 0, 109, 0, 111, 0, 113, 0, 115, 0, 117, 0, 119, 0, 121, 0, 123, 0, 125, 0, 127, 0, 129, 0, 131, 0, 133, 0, 135, 0, 137, 0, 139, 0, 141, 0, 143, 0, 145, 0, 147, 0, 149, 0, 151, 50, 153, 51, 155, 52, 157, 53, 159, 54, 161, 55, 163, 56, 165, 57, 1, 0, 32, 2, 0, 65, 65, 97, 97, 2, 0, 66, 66, 98, 98, 2, 0, 67, 67, 99, 99, 2, 0, 68, 68, 100, 100, 2, 0, 69, 69, 101, 101, 2, 0, 70, 70, 102, 102, 2, 0, 71, 71, 103, 103, 2, 0, 72, 72, 104, 104, 2, 0, 73, 73, 105, 105, 2, 0, 74, 74, 106, 106, 2, 0, 75, 75, 107, 107, 2, 0, 76, 76, 108, 108, 2, 0, 77, 77, 109, 109, 2, 0, 78, 78, 110, 110, 2, 0, 79, 79, 111, 111, 2, 0, 80, 80, 112, 112, 2, 0, 81, 81, 113, 113, 2, 0, 82, 82, 114, 114, 2, 0, 83, 83, 115, 115, 2, 0, 84, 84, 116, 116, 2, 0, 85, 85, 117, 117, 2, 0, 86, 86, 118, 118, 2, 0, 87, 87, 119, 119, 2, 0, 88, 88, 120, 120, 2, 0, 89, 89, 121, 121, 2, 0, 90, 90, 122, 122, 3, 0, 65, 90, 95, 95, 97, 122, 4, 0, 48, 57, 65, 90, 95, 95, 97, 122, 1, 0, 48, 57, 2, 0, 39, 39, 92, 92, 2, 0, 10, 10, 13, 13, 3, 0, 9, 10, 13, 13, 32, 32, 479, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39, 1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0, 47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0, 0, 55, 1, 0, 0, 0, 0, 57, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 61, 1, 0, 0, 0, 0, 63, 1, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 67, 1, 0, 0, 0, 0, 69, 1, 0, 0, 0, 0, 71, 1, 0, 0, 0, 0, 73, 1, 0, 0, 0, 0, 75, 1, 0, 0, 0, 0, 77, 1, 0, 0, 0, 0, 79, 1, 0, 0, 0, 0, 81, 1, 0, 0, 0, 0, 83, 1, 0, 0, 0, 0, 85, 1, 0, 0, 0, 0, 87, 1, 0, 0, 0, 0, 89, 1, 0, 0, 0, 0, 91, 1, 0, 0, 0, 0, 93, 1, 0, 0, 0, 0, 95, 1, 0, 0, 0, 0, 97, 1, 0, 0, 0, 0, 151, 1, 0, 0, 0, 0, 153, 1, 0, 0, 0, 0, 155, 1, 0, 0, 0, 0, 157, 1, 0, 0, 0, 0, 159, 1, 0, 0, 0, 0, 161, 1, 0, 0, 0, 0, 163, 1, 0, 0, 0, 0, 165, 1, 0, 0, 0, 1, 167, 1, 0, 0, 0, 3, 169, 1, 0, 0, 0, 5, 171, 1, 0, 0, 0, 7, 173, 1, 0, 0, 0, 9, 177, 1, 0, 0, 0, 11, 179, 1, 0, 0, 0, 13, 182, 1, 0, 0, 0, 15, 185, 1, 0, 0, 0, 17, 188, 1, 0, 0, 0, 19, 190, 1, 0, 0, 0, 21, 192, 1, 0, 0, 0, 23, 194, 1, 0, 0, 0, 25, 197, 1, 0, 0, 0, 27, 199, 1, 0, 0, 0, 29, 201, 1, 0, 0, 0, 31, 204, 1, 0, 0, 0, 33, 207, 1, 0, 0, 0, 35, 209, 1, 0, 0, 0, 37, 211, 1, 0, 0, 0, 39, 214, 1, 0, 0, 0, 41, 217, 1, 0, 0, 0, 43, 221, 1, 0, 0, 0, 45, 226, 1, 0, 0, 0, 47, 230, 1, 0, 0, 0, 49, 233, 1, 0, 0, 0, 51, 242, 1, 0, 0, 0, 53, 249, 1, 0, 0, 0, 55, 254, 1, 0, 0, 0, 57, 257, 1, 0, 0, 0, 59, 261, 1, 0, 0, 0, 61, 264, 1, 0, 0, 0, 63, 267, 1, 0, 0, 0, 65, 272, 1, 0, 0, 0, 67, 277, 1, 0, 0, 0, 69, 282, 1, 0, 0, 0, 71, 287, 1, 0, 0, 0, 73, 291, 1, 0, 0, 0, 75, 296, 1, 0, 0, 0, 77, 301, 1, 0, 0, 0, 79, 311, 1, 0, 0, 0, 81, 317, 1, 0, 0, 0, 83, 320, 1, 0, 0, 0, 85, 329, 1, 0, 0, 0, 87, 337, 1, 0, 0, 0, 89, 342, 1, 0, 0, 0, 91, 351, 1, 0, 0, 0, 93, 364, 1, 0, 0, 0, 95, 375, 1, 0, 0, 0, 97, 378, 1, 0, 0, 0, 99, 383, 1, 0, 0, 0, 101, 385, 1, 0, 0, 0, 103, 387, 1, 0, 0, 0, 105, 389, 1, 0, 0, 0, 107, 391, 1, 0, 0, 0, 109, 393, 1, 0, 0, 0, 111, 395, 1, 0, 0, 0, 113, 397, 1, 0, 0, 0, 115, 399, 1, 0, 0, 0, 117, 401, 1, 0, 0, 0, 119, 403, 1, 0, 0, 0, 121, 405, 1, 0, 0, 0, 123, 407, 1, 0, 0, 0, 125, 409, 1, 0, 0, 0, 127, 411, 1, 0, 0, 0, 129, 413, 1, 0, 0, 0, 131, 415, 1, 0, 0, 0, 133, 417, 1, 0, 0, 0, 135, 419, 1, 0, 0, 0, 137, 421, 1, 0, 0, 0, 139, 423, 1, 0, 0, 0, 141, 425, 1, 0, 0, 0, 143, 427, 1, 0, 0, 0, 145, 429, 1, 0, 0, 0, 147, 431, 1, 0, 0, 0, 149, 433, 1, 0, 0, 0, 151, 435, 1, 0, 0, 0, 153, 443, 1, 0, 0, 0, 155, 451, 1, 0, 0, 0, 157, 456, 1, 0, 0, 0, 159, 466, 1, 0, 0, 0, 161, 477, 1, 0, 0, 0, 163, 489, 1, 0, 0, 0, 165, 495, 1, 0, 0, 0, 167, 168, 5, 44, 0, 0, 168, 2, 1, 0, 0, 0, 169, 170, 5, 40, 0, 0, 170, 4, 1, 0, 0, 0, 171, 172, 5, 41, 0, 0, 172, 6, 1, 0, 0, 0, 173, 174, 5, 108, 0, 0, 174, 175, 5, 101, 0, 0, 175, 176, 5, 116, 0, 0, 176, 8, 1, 0, 0, 0, 177, 178, 5, 61, 0, 0, 178, 10, 1, 0, 0, 0, 179, 180, 5, 124, 0, 0, 180, 181, 5, 62, 0, 0, 181, 12, 1, 0, 0, 0, 182, 183, 5, 102, 0, 0, 183, 184, 5, 110, 0, 0, 184, 14, 1, 0, 0, 0, 185, 186, 5, 61, 0, 0, 186, 187, 5, 62, 0, 0, 187, 16, 1, 0, 0, 0, 188, 189, 5, 46, 0, 0, 189, 18, 1, 0, 0, 0, 190, 191, 5, 43, 0, 0, 191, 20, 1, 0, 0, 0, 192, 193, 5, 45, 0, 0, 193, 22, 1, 0, 0, 0, 194, 195, 5, 124, 0, 0, 195, 196, 5, 124, 0, 0, 196, 24, 1, 0, 0, 0, 197, 198, 5, 47, 0, 0, 198, 26, 1, 0, 0, 0, 199, 200, 5, 37, 0, 0, 200, 28, 1, 0, 0, 0, 201, 202, 5, 33, 0, 0, 202, 203, 5, 61, 0, 0, 203, 30, 1, 0, 0, 0, 204, 205, 5, 60, 0, 0, 205, 206, 5, 62, 0, 0, 206, 32, 1, 0, 0, 0, 207, 208, 5, 60, 0, 0, 208, 34, 1, 0, 0, 0, 209, 210, 5, 62, 0, 0, 210, 36, 1, 0, 0, 0, 211, 212, 5, 60, 0, 0, 212, 213, 5, 61, 0, 0, 213, 38, 1, 0, 0, 0, 214, 215, 5, 62, 0, 0, 215, 216, 5, 61, 0, 0, 216, 40, 1, 0, 0, 0, 217, 218, 3, 99, 49, 0, 218, 219, 3, 135, 67, 0, 219, 220, 3, 103, 51, 0, 220, 42, 1, 0, 0, 0, 221, 222, 3, 105, 52, 0, 222, 223, 3, 107, 53, 0, 223, 224, 3, 135, 67, 0, 224, 225, 3, 103, 51, 0, 225, 44, 1, 0, 0, 0, 226, 227, 3, 99, 49, 0, 227, 228, 3, 125, 62, 0, 228, 229, 3, 105, 52, 0, 229, 46, 1, 0, 0, 0, 230, 231, 3, 127, 63, 0, 231, 232, 3, 133, 66, 0, 232, 48, 1, 0, 0, 0, 233, 234, 3, 105, 52, 0, 234, 235, 3, 115, 57, 0, 235, 236, 3, 135, 67, 0, 236, 237, 3, 137, 68, 0, 237, 238, 3, 115, 57, 0, 238, 239, 3, 125, 62, 0, 239, 240, 3, 103, 51, 0, 240, 241, 3, 137, 68, 0, 241, 50, 1, 0, 0, 0, 242, 243, 3, 107, 53, 0, 243, 244, 3, 145, 72, 0, 244, 245, 3, 115, 57, 0, 245, 246, 3, 135, 67, 0, 246, 247, 3, 137, 68, 0, 247, 248, 3, 135, 67, 0, 248, 52, 1, 0, 0, 0, 249, 250, 3, 125, 62, 0, 250, 251, 3, 139, 69, 0, 251, 252, 3, 121, 60, 0, 252, 253, 3, 121, 60, 0, 253, 54, 1, 0, 0, 0, 254, 255, 3, 115, 57, 0, 255, 256, 3, 135, 67, 0, 256, 56, 1, 0, 0, 0, 257, 258, 3, 125, 62, 0, 258, 259, 3, 127, 63, 0, 259, 260, 3, 137, 68, 0, 260, 58, 1, 0, 0, 0, 261, 262, 3, 115, 57, 0, 262, 263, 3, 125, 62, 0, 263, 60, 1, 0, 0, 0, 264, 265, 3, 99, 49, 0, 265, 266, 3, 135, 67, 0, 266, 62, 1, 0, 0, 0, 267, 268, 3, 103, 51, 0, 268, 269, 3, 99, 49, 0, 269, 270, 3, 135, 67, 0, 270, 271, 3, 107, 53, 0, 271, 64, 1, 0, 0, 0, 272, 273, 3, 143, 71, 0, 273, 274, 3, 113, 56, 0, 274, 275, 3, 107, 53, 0, 275, 276, 3, 125, 62, 0, 276, 66, 1, 0, 0, 0, 277, 278, 3, 137, 68, 0, 278, 279, 3, 113, 56, 0, 279, 280, 3, 107, 53, 0, 280, 281, 3, 125, 62, 0, 281, 68, 1, 0, 0, 0, 282, 283, 3, 107, 53, 0, 283, 284, 3, 121, 60, 0, 284, 285, 3, 135, 67, 0, 285, 286, 3, 107, 53, 0, 286, 70, 1, 0, 0, 0, 287, 288, 3, 107, 53, 0, 288, 289, 3, 125, 62, 0, 289, 290, 3, 105, 52, 0, 290, 72, 1, 0, 0, 0, 291, 292, 3, 143, 71, 0, 292, 293, 3, 115, 57, 0, 293, 294, 3, 137, 68, 0, 294, 295, 3, 113, 56, 0, 295, 74, 1, 0, 0, 0, 296, 297, 3, 127, 63, 0, 297, 298, 3, 141, 70, 0, 298, 299, 3, 107, 53, 0, 299, 300, 3, 133, 66, 0, 300, 76, 1, 0, 0, 0, 301, 302, 3, 129, 64, 0, 302, 303, 3, 99, 49, 0, 303, 304, 3, 133, 66, 0, 304, 305, 3, 137, 68, 0, 305, 306, 3, 115, 57, 0, 306, 307, 3, 137, 68, 0, 307, 308, 3, 115, 57, 0, 308, 309, 3, 127, 63, 0, 309, 310, 3, 125, 62, 0, 310, 78, 1, 0, 0, 0, 311, 312, 3, 127, 63, 0, 312, 313, 3, 133, 66, 0, 313, 314, 3, 105, 52, 0, 314, 315, 3, 107, 53, 0, 315, 316, 3, 133, 66, 0, 316, 80, 1, 0, 0, 0, 317, 318, 3, 101, 50, 0, 318, 319, 3, 147, 73, 0, 319, 82, 1, 0, 0, 0, 320, 321, 3, 103, 51, 0, 321, 322, 3, 127, 63, 0, 322, 323, 3, 99, 49, 0, 323, 324, 3, 121, 60, 0, 324, 325, 3, 107, 53, 0, 325, 326, 3, 135, 67, 0, 326, 327, 3, 103, 51, 0, 327, 328, 3, 107, 53, 0, 328, 84, 1, 0, 0, 0, 329, 330, 3, 107, 53, 0, 330, 331, 3, 145, 72, 0, 331, 332, 3, 137, 68, 0, 332, 333, 3, 133, 66, 0, 333, 334, 3, 99, 49, 0, 334, 335, 3, 103, 51, 0, 335, 336, 3, 137, 68, 0, 336, 86, 1, 0, 0, 0, 337, 338, 3, 109, 54, 0, 338, 339, 3, 133, 66, 0, 339, 340, 3, 127, 63, 0, 340, 341, 3, 123, 61, 0, 341, 88, 1, 0, 0, 0, 342, 343, 3, 115, 57, 0, 343, 344, 3, 125, 62, 0, 344, 345, 3, 137, 68, 0, 345, 346, 3, 107, 53, 0, 346, 347, 3, 133, 66, 0, 347, 348, 3, 141, 70, 0, 348, 349, 3, 99, 49, 0, 349, 350, 3, 121, 60, 0, 350, 90, 1, 0, 0, 0, 351, 352, 3, 103, 51, 0, 352, 353, 3, 139, 69, 0, 353, 354, 3, 133, 66, 0, 354, 355, 3, 133, 66, 0, 355, 356, 3, 107, 53, 0, 356, 357, 3, 125, 62, 0, 357, 358, 3, 137, 68, 0, 358, 359, 5, 95, 0, 0, 359, 360, 3, 105, 52, 0, 360, 361, 3, 99, 49, 0, 361, 362, 3, 137, 68, 0, 362, 363, 3, 107, 53, 0, 363, 92, 1, 0, 0, 0, 364, 365, 3, 105, 52, 0, 365, 366, 3, 99, 49, 0, 366, 367, 3, 137, 68, 0, 367, 368, 3, 107, 53, 0, 368, 369, 5, 95, 0, 0, 369, 370, 3, 137, 68, 0, 370, 371, 3, 133, 66, 0, 371, 372, 3, 139, 69, 0, 372, 373, 3, 125, 62, 0, 373, 374, 3, 103, 51, 0, 374, 94, 1, 0, 0, 0, 375, 376, 3, 127, 63, 0, 376, 377, 3, 125, 62, 0, 377, 96, 1, 0, 0, 0, 378, 379, 3, 121, 60, 0, 379, 380, 3, 115, 57, 0, 380, 381, 3, 119, 59, 0, 381, 382, 3, 107, 53, 0, 382, 98, 1, 0, 0, 0, 383, 384, 7, 0, 0, 0, 384, 100, 1, 0, 0, 0, 385, 386, 7, 1, 0, 0, 386, 102, 1, 0, 0, 0, 387, 388, 7, 2, 0, 0, 388, 104, 1, 0, 0, 0, 389, 390, 7, 3, 0, 0, 390, 106, 1, 0, 0, 0, 391, 392, 7, 4, 0, 0, 392, 108, 1, 0, 0, 0, 393, 394, 7, 5, 0, 0, 394, 110, 1, 0, 0, 0, 395, 396, 7, 6, 0, 0, 396, 112, 1, 0, 0, 0, 397, 398, 7, 7, 0, 0, 398, 114, 1, 0, 0, 0, 399, 400, 7, 8, 0, 0, 400, 116, 1, 0, 0, 0, 401, 402, 7, 9, 0, 0, 402, 118, 1, 0, 0, 0, 403, 404, 7, 10, 0, 0, 404, 120, 1, 0, 0, 0, 405, 406, 7, 11, 0, 0, 406, 122, 1, 0, 0, 0, 407, 408, 7, 12, 0, 0, 408, 124, 1, 0, 0, 0, 409, 410, 7, 13, 0, 0, 410, 126, 1, 0, 0, 0, 411, 412, 7, 14, 0, 0, 412, 128, 1, 0, 0, 0, 413, 414, 7, 15, 0, 0, 414, 130, 1, 0, 0, 0, 415, 416, 7, 16, 0, 0, 416, 132, 1, 0, 0, 0, 417, 418, 7, 17, 0, 0, 418, 134, 1, 0, 0, 0, 419, 420, 7, 18, 0, 0, 420, 136, 1, 0, 0, 0, 421, 422, 7, 19, 0, 0, 422, 138, 1, 0, 0, 0, 423, 424, 7, 20, 0, 0, 424, 140, 1, 0, 0, 0, 425, 426, 7, 21, 0, 0, 426, 142, 1, 0, 0, 0, 427, 428, 7, 22, 0, 0, 428, 144, 1, 0, 0, 0, 429, 430, 7, 23, 0, 0, 430, 146, 1, 0, 0, 0, 431, 432, 7, 24, 0, 0, 432, 148, 1, 0, 0, 0, 433, 434, 7, 25, 0, 0, 434, 150, 1, 0, 0, 0, 435, 436, 5, 64, 0, 0, 436, 440, 7, 26, 0, 0, 437, 439, 7, 27, 0, 0, 438, 437, 1, 0, 0, 0, 439, 442, 1, 0, 0, 0, 440, 438, 1, 0, 0, 0, 440, 441, 1, 0, 0, 0, 441, 152, 1, 0, 0, 0, 442, 440, 1, 0, 0, 0, 443, 447, 7, 26, 0, 0, 444, 446, 7, 27, 0, 0, 445, 444, 1, 0, 0, 0, 446, 449, 1, 0, 0, 0, 447, 445, 1, 0, 0, 0, 447, 448, 1, 0, 0, 0, 448, 154, 1, 0, 0, 0, 449, 447, 1, 0, 0, 0, 450, 452, 7, 28, 0, 0, 451, 450, 1, 0, 0, 0, 452, 453, 1, 0, 0, 0, 453, 451, 1, 0, 0, 0, 453, 454, 1, 0, 0, 0, 454, 156, 1, 0, 0, 0, 455, 457, 7, 28, 0, 0, 456, 455, 1, 0, 0, 0, 457, 458, 1, 0, 0, 0, 458, 456, 1, 0, 0, 0, 458, 459, 1, 0, 0, 0, 459, 460, 1, 0, 0, 0, 460, 462, 5, 46, 0, 0, 461, 463, 7, 28, 0, 0, 462, 461, 1, 0, 0, 0, 463, 464, 1, 0, 0, 0, 464, 462, 1, 0, 0, 0, 464, 465, 1, 0, 0, 0, 465, 158, 1, 0, 0, 0, 466, 472, 5, 39, 0, 0, 467, 471, 8, 29, 0, 0, 468, 469, 5, 92, 0, 0, 469, 471, 9, 0, 0, 0, 470, 467, 1, 0, 0, 0, 470, 468, 1, 0, 0, 0, 471, 474, 1, 0, 0, 0, 472, 470, 1, 0, 0, 0, 472, 473, 1, 0, 0, 0, 473, 475, 1, 0, 0, 0, 474, 472, 1, 0, 0, 0, 475, 476, 5, 39, 0, 0, 476, 160, 1, 0, 0, 0, 477, 478, 5, 45, 0, 0, 478, 479, 5, 45, 0, 0, 479, 483, 1, 0, 0, 0, 480, 482, 8, 30, 0, 0, 481, 480, 1, 0, 0, 0, 482, 485, 1, 0, 0, 0, 483, 481, 1, 0, 0, 0, 483, 484, 1, 0, 0, 0, 484, 486, 1, 0, 0, 0, 485, 483, 1, 0, 0, 0, 486, 487, 6, 80, 0, 0, 487, 162, 1, 0, 0, 0, 488, 490, 7, 31, 0, 0, 489, 488, 1, 0, 0, 0, 490, 491, 1, 0, 0, 0, 491, 489, 1, 0, 0, 0, 491, 492, 1, 0, 0, 0, 492, 493, 1, 0, 0, 0, 493, 494, 6, 81, 0, 0, 494, 164, 1, 0, 0, 0, 495, 496, 5, 42, 0, 0, 496, 166, 1, 0, 0, 0, 10, 0, 440, 447, 453, 458, 464, 470, 472, 483, 491, 1, 6, 0, 0] \ No newline at end of file diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.tokens b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.tokens index e4a99d28..2c5313b1 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.tokens +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlLexer.tokens @@ -55,13 +55,13 @@ STRING=54 COMMENT=55 WS=56 ASTERISK=57 -'let'=1 -'='=2 -'|>'=3 -'('=4 -')'=5 -'fn'=6 -','=7 +','=1 +'('=2 +')'=3 +'let'=4 +'='=5 +'|>'=6 +'fn'=7 '=>'=8 '.'=9 '+'=10 diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs index 68ece03f..5ecead52 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlListener.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 +// Generated from Lql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -52,6 +52,26 @@ public interface ILqlListener : IParseTreeListener { /// The parse tree. void ExitStatement([NotNull] LqlParser.StatementContext context); /// + /// Enter a parse tree produced by . + /// + /// The parse tree. + void EnterWithStmt([NotNull] LqlParser.WithStmtContext context); + /// + /// Exit a parse tree produced by . + /// + /// The parse tree. + void ExitWithStmt([NotNull] LqlParser.WithStmtContext context); + /// + /// Enter a parse tree produced by . + /// + /// The parse tree. + void EnterCteDef([NotNull] LqlParser.CteDefContext context); + /// + /// Exit a parse tree produced by . + /// + /// The parse tree. + void ExitCteDef([NotNull] LqlParser.CteDefContext context); + /// /// Enter a parse tree produced by . /// /// The parse tree. @@ -112,6 +132,26 @@ public interface ILqlListener : IParseTreeListener { /// The parse tree. void ExitOrderClause([NotNull] LqlParser.OrderClauseContext context); /// + /// Enter a parse tree produced by . + /// + /// The parse tree. + void EnterOrderByArgList([NotNull] LqlParser.OrderByArgListContext context); + /// + /// Exit a parse tree produced by . + /// + /// The parse tree. + void ExitOrderByArgList([NotNull] LqlParser.OrderByArgListContext context); + /// + /// Enter a parse tree produced by . + /// + /// The parse tree. + void EnterOrderByArg([NotNull] LqlParser.OrderByArgContext context); + /// + /// Exit a parse tree produced by . + /// + /// The parse tree. + void ExitOrderByArg([NotNull] LqlParser.OrderByArgContext context); + /// /// Enter a parse tree produced by . /// /// The parse tree. diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs index 7bfc42a6..e1b06d72 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlParser.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 +// Generated from Lql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -46,26 +46,27 @@ public const int LIKE=49, PARAMETER=50, IDENT=51, INT=52, DECIMAL=53, STRING=54, COMMENT=55, WS=56, ASTERISK=57; public const int - RULE_program = 0, RULE_statement = 1, RULE_letStmt = 2, RULE_pipeExpr = 3, - RULE_expr = 4, RULE_windowSpec = 5, RULE_partitionClause = 6, RULE_orderClause = 7, - RULE_lambdaExpr = 8, RULE_qualifiedIdent = 9, RULE_argList = 10, RULE_arg = 11, - RULE_columnAlias = 12, RULE_arithmeticExpr = 13, RULE_arithmeticTerm = 14, - RULE_arithmeticFactor = 15, RULE_functionCall = 16, RULE_namedArg = 17, - RULE_logicalExpr = 18, RULE_andExpr = 19, RULE_atomicExpr = 20, RULE_comparison = 21, - RULE_existsExpr = 22, RULE_nullCheckExpr = 23, RULE_inExpr = 24, RULE_caseExpr = 25, - RULE_whenClause = 26, RULE_caseResult = 27, RULE_orderDirection = 28, - RULE_comparisonOp = 29; + RULE_program = 0, RULE_statement = 1, RULE_withStmt = 2, RULE_cteDef = 3, + RULE_letStmt = 4, RULE_pipeExpr = 5, RULE_expr = 6, RULE_windowSpec = 7, + RULE_partitionClause = 8, RULE_orderClause = 9, RULE_orderByArgList = 10, + RULE_orderByArg = 11, RULE_lambdaExpr = 12, RULE_qualifiedIdent = 13, + RULE_argList = 14, RULE_arg = 15, RULE_columnAlias = 16, RULE_arithmeticExpr = 17, + RULE_arithmeticTerm = 18, RULE_arithmeticFactor = 19, RULE_functionCall = 20, + RULE_namedArg = 21, RULE_logicalExpr = 22, RULE_andExpr = 23, RULE_atomicExpr = 24, + RULE_comparison = 25, RULE_existsExpr = 26, RULE_nullCheckExpr = 27, RULE_inExpr = 28, + RULE_caseExpr = 29, RULE_whenClause = 30, RULE_caseResult = 31, RULE_orderDirection = 32, + RULE_comparisonOp = 33; public static readonly string[] ruleNames = { - "program", "statement", "letStmt", "pipeExpr", "expr", "windowSpec", "partitionClause", - "orderClause", "lambdaExpr", "qualifiedIdent", "argList", "arg", "columnAlias", - "arithmeticExpr", "arithmeticTerm", "arithmeticFactor", "functionCall", - "namedArg", "logicalExpr", "andExpr", "atomicExpr", "comparison", "existsExpr", - "nullCheckExpr", "inExpr", "caseExpr", "whenClause", "caseResult", "orderDirection", - "comparisonOp" + "program", "statement", "withStmt", "cteDef", "letStmt", "pipeExpr", "expr", + "windowSpec", "partitionClause", "orderClause", "orderByArgList", "orderByArg", + "lambdaExpr", "qualifiedIdent", "argList", "arg", "columnAlias", "arithmeticExpr", + "arithmeticTerm", "arithmeticFactor", "functionCall", "namedArg", "logicalExpr", + "andExpr", "atomicExpr", "comparison", "existsExpr", "nullCheckExpr", + "inExpr", "caseExpr", "whenClause", "caseResult", "orderDirection", "comparisonOp" }; private static readonly string[] _LiteralNames = { - null, "'let'", "'='", "'|>'", "'('", "')'", "'fn'", "','", "'=>'", "'.'", + null, "','", "'('", "')'", "'let'", "'='", "'|>'", "'fn'", "'=>'", "'.'", "'+'", "'-'", "'||'", "'/'", "'%'", "'!='", "'<>'", "'<'", "'>'", "'<='", "'>='", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, @@ -91,7 +92,7 @@ public override IVocabulary Vocabulary } } - public override string GrammarFileName { get { return "Nimblesite.Lql.Core.g4"; } } + public override string GrammarFileName { get { return "Lql.g4"; } } public override string[] RuleNames { get { return ruleNames; } } @@ -151,21 +152,21 @@ public ProgramContext program() { try { EnterOuterAlt(_localctx, 1); { - State = 63; + State = 71; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179018089482944594L) != 0)) { + while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179018226921898132L) != 0)) { { { - State = 60; + State = 68; statement(); } } - State = 65; + State = 73; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 66; + State = 74; Match(Eof); } } @@ -181,6 +182,9 @@ public ProgramContext program() { } public partial class StatementContext : ParserRuleContext { + [System.Diagnostics.DebuggerNonUserCode] public WithStmtContext withStmt() { + return GetRuleContext(0); + } [System.Diagnostics.DebuggerNonUserCode] public LetStmtContext letStmt() { return GetRuleContext(0); } @@ -215,18 +219,25 @@ public StatementContext statement() { StatementContext _localctx = new StatementContext(Context, State); EnterRule(_localctx, 2, RULE_statement); try { - State = 70; + State = 79; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { - case T__0: + case WITH: EnterOuterAlt(_localctx, 1); { - State = 68; - letStmt(); + State = 76; + withStmt(); } break; case T__3: - case T__5: + EnterOuterAlt(_localctx, 2); + { + State = 77; + letStmt(); + } + break; + case T__1: + case T__6: case CASE: case PARAMETER: case IDENT: @@ -234,9 +245,9 @@ public StatementContext statement() { case DECIMAL: case STRING: case ASTERISK: - EnterOuterAlt(_localctx, 2); + EnterOuterAlt(_localctx, 3); { - State = 69; + State = 78; pipeExpr(); } break; @@ -255,6 +266,142 @@ public StatementContext statement() { return _localctx; } + public partial class WithStmtContext : ParserRuleContext { + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode WITH() { return GetToken(LqlParser.WITH, 0); } + [System.Diagnostics.DebuggerNonUserCode] public CteDefContext[] cteDef() { + return GetRuleContexts(); + } + [System.Diagnostics.DebuggerNonUserCode] public CteDefContext cteDef(int i) { + return GetRuleContext(i); + } + [System.Diagnostics.DebuggerNonUserCode] public PipeExprContext pipeExpr() { + return GetRuleContext(0); + } + public WithStmtContext(ParserRuleContext parent, int invokingState) + : base(parent, invokingState) + { + } + public override int RuleIndex { get { return RULE_withStmt; } } + [System.Diagnostics.DebuggerNonUserCode] + public override void EnterRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.EnterWithStmt(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override void ExitRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.ExitWithStmt(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override TResult Accept(IParseTreeVisitor visitor) { + ILqlVisitor typedVisitor = visitor as ILqlVisitor; + if (typedVisitor != null) return typedVisitor.VisitWithStmt(this); + else return visitor.VisitChildren(this); + } + } + + [RuleVersion(0)] + public WithStmtContext withStmt() { + WithStmtContext _localctx = new WithStmtContext(Context, State); + EnterRule(_localctx, 4, RULE_withStmt); + int _la; + try { + EnterOuterAlt(_localctx, 1); + { + State = 81; + Match(WITH); + State = 82; + cteDef(); + State = 87; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + while (_la==T__0) { + { + { + State = 83; + Match(T__0); + State = 84; + cteDef(); + } + } + State = 89; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + } + State = 90; + pipeExpr(); + } + } + catch (RecognitionException re) { + _localctx.exception = re; + ErrorHandler.ReportError(this, re); + ErrorHandler.Recover(this, re); + } + finally { + ExitRule(); + } + return _localctx; + } + + public partial class CteDefContext : ParserRuleContext { + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode IDENT() { return GetToken(LqlParser.IDENT, 0); } + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode AS() { return GetToken(LqlParser.AS, 0); } + [System.Diagnostics.DebuggerNonUserCode] public PipeExprContext pipeExpr() { + return GetRuleContext(0); + } + public CteDefContext(ParserRuleContext parent, int invokingState) + : base(parent, invokingState) + { + } + public override int RuleIndex { get { return RULE_cteDef; } } + [System.Diagnostics.DebuggerNonUserCode] + public override void EnterRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.EnterCteDef(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override void ExitRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.ExitCteDef(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override TResult Accept(IParseTreeVisitor visitor) { + ILqlVisitor typedVisitor = visitor as ILqlVisitor; + if (typedVisitor != null) return typedVisitor.VisitCteDef(this); + else return visitor.VisitChildren(this); + } + } + + [RuleVersion(0)] + public CteDefContext cteDef() { + CteDefContext _localctx = new CteDefContext(Context, State); + EnterRule(_localctx, 6, RULE_cteDef); + try { + EnterOuterAlt(_localctx, 1); + { + State = 92; + Match(IDENT); + State = 93; + Match(AS); + State = 94; + Match(T__1); + State = 95; + pipeExpr(); + State = 96; + Match(T__2); + } + } + catch (RecognitionException re) { + _localctx.exception = re; + ErrorHandler.ReportError(this, re); + ErrorHandler.Recover(this, re); + } + finally { + ExitRule(); + } + return _localctx; + } + public partial class LetStmtContext : ParserRuleContext { [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode IDENT() { return GetToken(LqlParser.IDENT, 0); } [System.Diagnostics.DebuggerNonUserCode] public PipeExprContext pipeExpr() { @@ -286,17 +433,17 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public LetStmtContext letStmt() { LetStmtContext _localctx = new LetStmtContext(Context, State); - EnterRule(_localctx, 4, RULE_letStmt); + EnterRule(_localctx, 8, RULE_letStmt); try { EnterOuterAlt(_localctx, 1); { - State = 72; - Match(T__0); - State = 73; + State = 98; + Match(T__3); + State = 99; Match(IDENT); - State = 74; - Match(T__1); - State = 75; + State = 100; + Match(T__4); + State = 101; pipeExpr(); } } @@ -344,26 +491,26 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public PipeExprContext pipeExpr() { PipeExprContext _localctx = new PipeExprContext(Context, State); - EnterRule(_localctx, 6, RULE_pipeExpr); + EnterRule(_localctx, 10, RULE_pipeExpr); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 77; + State = 103; expr(); - State = 82; + State = 108; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while (_la==T__2) { + while (_la==T__5) { { { - State = 78; - Match(T__2); - State = 79; + State = 104; + Match(T__5); + State = 105; expr(); } } - State = 84; + State = 110; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -432,133 +579,133 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ExprContext expr() { ExprContext _localctx = new ExprContext(Context, State); - EnterRule(_localctx, 8, RULE_expr); + EnterRule(_localctx, 12, RULE_expr); int _la; try { - State = 115; + State = 141; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,5,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,6,Context) ) { case 1: EnterOuterAlt(_localctx, 1); { - State = 85; + State = 111; Match(IDENT); - State = 86; - Match(T__3); - State = 88; + State = 112; + Match(T__1); + State = 114; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179299564526764112L) != 0)) { + if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179299564526764164L) != 0)) { { - State = 87; + State = 113; argList(); } } - State = 90; - Match(T__4); - State = 91; + State = 116; + Match(T__2); + State = 117; Match(OVER); - State = 92; - Match(T__3); - State = 93; + State = 118; + Match(T__1); + State = 119; windowSpec(); - State = 94; - Match(T__4); + State = 120; + Match(T__2); } break; case 2: EnterOuterAlt(_localctx, 2); { - State = 96; + State = 122; Match(IDENT); - State = 97; - Match(T__3); - State = 99; + State = 123; + Match(T__1); + State = 125; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179299564526764112L) != 0)) { + if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179299564526764164L) != 0)) { { - State = 98; + State = 124; argList(); } } - State = 101; - Match(T__4); + State = 127; + Match(T__2); } break; case 3: EnterOuterAlt(_localctx, 3); { - State = 102; + State = 128; Match(IDENT); } break; case 4: EnterOuterAlt(_localctx, 4); { - State = 103; - Match(T__3); - State = 104; + State = 129; + Match(T__1); + State = 130; pipeExpr(); - State = 105; - Match(T__4); + State = 131; + Match(T__2); } break; case 5: EnterOuterAlt(_localctx, 5); { - State = 107; + State = 133; qualifiedIdent(); } break; case 6: EnterOuterAlt(_localctx, 6); { - State = 108; + State = 134; lambdaExpr(); } break; case 7: EnterOuterAlt(_localctx, 7); { - State = 109; + State = 135; caseExpr(); } break; case 8: EnterOuterAlt(_localctx, 8); { - State = 110; + State = 136; Match(INT); } break; case 9: EnterOuterAlt(_localctx, 9); { - State = 111; + State = 137; Match(DECIMAL); } break; case 10: EnterOuterAlt(_localctx, 10); { - State = 112; + State = 138; Match(ASTERISK); } break; case 11: EnterOuterAlt(_localctx, 11); { - State = 113; + State = 139; Match(STRING); } break; case 12: EnterOuterAlt(_localctx, 12); { - State = 114; + State = 140; Match(PARAMETER); } break; @@ -608,27 +755,27 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public WindowSpecContext windowSpec() { WindowSpecContext _localctx = new WindowSpecContext(Context, State); - EnterRule(_localctx, 10, RULE_windowSpec); + EnterRule(_localctx, 14, RULE_windowSpec); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 118; + State = 144; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==PARTITION) { { - State = 117; + State = 143; partitionClause(); } } - State = 121; + State = 147; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ORDER) { { - State = 120; + State = 146; orderClause(); } } @@ -678,15 +825,15 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public PartitionClauseContext partitionClause() { PartitionClauseContext _localctx = new PartitionClauseContext(Context, State); - EnterRule(_localctx, 12, RULE_partitionClause); + EnterRule(_localctx, 16, RULE_partitionClause); try { EnterOuterAlt(_localctx, 1); { - State = 123; + State = 149; Match(PARTITION); - State = 124; + State = 150; Match(BY); - State = 125; + State = 151; argList(); } } @@ -704,8 +851,8 @@ public PartitionClauseContext partitionClause() { public partial class OrderClauseContext : ParserRuleContext { [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode ORDER() { return GetToken(LqlParser.ORDER, 0); } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode BY() { return GetToken(LqlParser.BY, 0); } - [System.Diagnostics.DebuggerNonUserCode] public ArgListContext argList() { - return GetRuleContext(0); + [System.Diagnostics.DebuggerNonUserCode] public OrderByArgListContext orderByArgList() { + return GetRuleContext(0); } public OrderClauseContext(ParserRuleContext parent, int invokingState) : base(parent, invokingState) @@ -733,16 +880,181 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public OrderClauseContext orderClause() { OrderClauseContext _localctx = new OrderClauseContext(Context, State); - EnterRule(_localctx, 14, RULE_orderClause); + EnterRule(_localctx, 18, RULE_orderClause); try { EnterOuterAlt(_localctx, 1); { - State = 127; + State = 153; Match(ORDER); - State = 128; + State = 154; Match(BY); - State = 129; - argList(); + State = 155; + orderByArgList(); + } + } + catch (RecognitionException re) { + _localctx.exception = re; + ErrorHandler.ReportError(this, re); + ErrorHandler.Recover(this, re); + } + finally { + ExitRule(); + } + return _localctx; + } + + public partial class OrderByArgListContext : ParserRuleContext { + [System.Diagnostics.DebuggerNonUserCode] public OrderByArgContext[] orderByArg() { + return GetRuleContexts(); + } + [System.Diagnostics.DebuggerNonUserCode] public OrderByArgContext orderByArg(int i) { + return GetRuleContext(i); + } + public OrderByArgListContext(ParserRuleContext parent, int invokingState) + : base(parent, invokingState) + { + } + public override int RuleIndex { get { return RULE_orderByArgList; } } + [System.Diagnostics.DebuggerNonUserCode] + public override void EnterRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.EnterOrderByArgList(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override void ExitRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.ExitOrderByArgList(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override TResult Accept(IParseTreeVisitor visitor) { + ILqlVisitor typedVisitor = visitor as ILqlVisitor; + if (typedVisitor != null) return typedVisitor.VisitOrderByArgList(this); + else return visitor.VisitChildren(this); + } + } + + [RuleVersion(0)] + public OrderByArgListContext orderByArgList() { + OrderByArgListContext _localctx = new OrderByArgListContext(Context, State); + EnterRule(_localctx, 20, RULE_orderByArgList); + int _la; + try { + EnterOuterAlt(_localctx, 1); + { + State = 157; + orderByArg(); + State = 162; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + while (_la==T__0) { + { + { + State = 158; + Match(T__0); + State = 159; + orderByArg(); + } + } + State = 164; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + } + } + } + catch (RecognitionException re) { + _localctx.exception = re; + ErrorHandler.ReportError(this, re); + ErrorHandler.Recover(this, re); + } + finally { + ExitRule(); + } + return _localctx; + } + + public partial class OrderByArgContext : ParserRuleContext { + [System.Diagnostics.DebuggerNonUserCode] public ArithmeticExprContext arithmeticExpr() { + return GetRuleContext(0); + } + [System.Diagnostics.DebuggerNonUserCode] public FunctionCallContext functionCall() { + return GetRuleContext(0); + } + [System.Diagnostics.DebuggerNonUserCode] public QualifiedIdentContext qualifiedIdent() { + return GetRuleContext(0); + } + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode IDENT() { return GetToken(LqlParser.IDENT, 0); } + [System.Diagnostics.DebuggerNonUserCode] public OrderDirectionContext orderDirection() { + return GetRuleContext(0); + } + public OrderByArgContext(ParserRuleContext parent, int invokingState) + : base(parent, invokingState) + { + } + public override int RuleIndex { get { return RULE_orderByArg; } } + [System.Diagnostics.DebuggerNonUserCode] + public override void EnterRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.EnterOrderByArg(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override void ExitRule(IParseTreeListener listener) { + ILqlListener typedListener = listener as ILqlListener; + if (typedListener != null) typedListener.ExitOrderByArg(this); + } + [System.Diagnostics.DebuggerNonUserCode] + public override TResult Accept(IParseTreeVisitor visitor) { + ILqlVisitor typedVisitor = visitor as ILqlVisitor; + if (typedVisitor != null) return typedVisitor.VisitOrderByArg(this); + else return visitor.VisitChildren(this); + } + } + + [RuleVersion(0)] + public OrderByArgContext orderByArg() { + OrderByArgContext _localctx = new OrderByArgContext(Context, State); + EnterRule(_localctx, 22, RULE_orderByArg); + int _la; + try { + EnterOuterAlt(_localctx, 1); + { + State = 169; + ErrorHandler.Sync(this); + switch ( Interpreter.AdaptivePredict(TokenStream,10,Context) ) { + case 1: + { + State = 165; + arithmeticExpr(); + } + break; + case 2: + { + State = 166; + functionCall(); + } + break; + case 3: + { + State = 167; + qualifiedIdent(); + } + break; + case 4: + { + State = 168; + Match(IDENT); + } + break; + } + State = 172; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + if (_la==ASC || _la==DESC) { + { + State = 171; + orderDirection(); + } + } + } } catch (RecognitionException re) { @@ -790,38 +1102,38 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public LambdaExprContext lambdaExpr() { LambdaExprContext _localctx = new LambdaExprContext(Context, State); - EnterRule(_localctx, 16, RULE_lambdaExpr); + EnterRule(_localctx, 24, RULE_lambdaExpr); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 131; - Match(T__5); - State = 132; - Match(T__3); - State = 133; + State = 174; + Match(T__6); + State = 175; + Match(T__1); + State = 176; Match(IDENT); - State = 138; + State = 181; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while (_la==T__6) { + while (_la==T__0) { { { - State = 134; - Match(T__6); - State = 135; + State = 177; + Match(T__0); + State = 178; Match(IDENT); } } - State = 140; + State = 183; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 141; - Match(T__4); - State = 142; + State = 184; + Match(T__2); + State = 185; Match(T__7); - State = 143; + State = 186; logicalExpr(); } } @@ -867,26 +1179,26 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public QualifiedIdentContext qualifiedIdent() { QualifiedIdentContext _localctx = new QualifiedIdentContext(Context, State); - EnterRule(_localctx, 18, RULE_qualifiedIdent); + EnterRule(_localctx, 26, RULE_qualifiedIdent); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 145; + State = 188; Match(IDENT); - State = 148; + State = 191; ErrorHandler.Sync(this); _la = TokenStream.LA(1); do { { { - State = 146; + State = 189; Match(T__8); - State = 147; + State = 190; Match(IDENT); } } - State = 150; + State = 193; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } while ( _la==T__8 ); @@ -936,26 +1248,26 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ArgListContext argList() { ArgListContext _localctx = new ArgListContext(Context, State); - EnterRule(_localctx, 20, RULE_argList); + EnterRule(_localctx, 28, RULE_argList); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 152; + State = 195; arg(); - State = 157; + State = 200; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while (_la==T__6) { + while (_la==T__0) { { { - State = 153; - Match(T__6); - State = 154; + State = 196; + Match(T__0); + State = 197; arg(); } } - State = 159; + State = 202; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -1026,83 +1338,83 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ArgContext arg() { ArgContext _localctx = new ArgContext(Context, State); - EnterRule(_localctx, 22, RULE_arg); + EnterRule(_localctx, 30, RULE_arg); try { - State = 173; + State = 216; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,11,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,15,Context) ) { case 1: EnterOuterAlt(_localctx, 1); { - State = 160; + State = 203; columnAlias(); } break; case 2: EnterOuterAlt(_localctx, 2); { - State = 161; + State = 204; arithmeticExpr(); } break; case 3: EnterOuterAlt(_localctx, 3); { - State = 162; + State = 205; functionCall(); } break; case 4: EnterOuterAlt(_localctx, 4); { - State = 163; + State = 206; caseExpr(); } break; case 5: EnterOuterAlt(_localctx, 5); { - State = 164; + State = 207; expr(); } break; case 6: EnterOuterAlt(_localctx, 6); { - State = 165; + State = 208; namedArg(); } break; case 7: EnterOuterAlt(_localctx, 7); { - State = 166; + State = 209; comparison(); } break; case 8: EnterOuterAlt(_localctx, 8); { - State = 167; + State = 210; pipeExpr(); } break; case 9: EnterOuterAlt(_localctx, 9); { - State = 168; + State = 211; lambdaExpr(); } break; case 10: EnterOuterAlt(_localctx, 10); { - State = 169; - Match(T__3); - State = 170; + State = 212; + Match(T__1); + State = 213; pipeExpr(); - State = 171; - Match(T__4); + State = 214; + Match(T__2); } break; } @@ -1159,47 +1471,47 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ColumnAliasContext columnAlias() { ColumnAliasContext _localctx = new ColumnAliasContext(Context, State); - EnterRule(_localctx, 24, RULE_columnAlias); + EnterRule(_localctx, 32, RULE_columnAlias); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 179; + State = 222; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,12,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,16,Context) ) { case 1: { - State = 175; + State = 218; arithmeticExpr(); } break; case 2: { - State = 176; + State = 219; functionCall(); } break; case 3: { - State = 177; + State = 220; qualifiedIdent(); } break; case 4: { - State = 178; + State = 221; Match(IDENT); } break; } - State = 183; + State = 226; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 181; + State = 224; Match(AS); - State = 182; + State = 225; Match(IDENT); } } @@ -1250,20 +1562,20 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ArithmeticExprContext arithmeticExpr() { ArithmeticExprContext _localctx = new ArithmeticExprContext(Context, State); - EnterRule(_localctx, 26, RULE_arithmeticExpr); + EnterRule(_localctx, 34, RULE_arithmeticExpr); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 185; + State = 228; arithmeticTerm(); - State = 190; + State = 233; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 7168L) != 0)) { { { - State = 186; + State = 229; _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 7168L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -1272,11 +1584,11 @@ public ArithmeticExprContext arithmeticExpr() { ErrorHandler.ReportMatch(this); Consume(); } - State = 187; + State = 230; arithmeticTerm(); } } - State = 192; + State = 235; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -1330,22 +1642,22 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ArithmeticTermContext arithmeticTerm() { ArithmeticTermContext _localctx = new ArithmeticTermContext(Context, State); - EnterRule(_localctx, 28, RULE_arithmeticTerm); + EnterRule(_localctx, 36, RULE_arithmeticTerm); int _la; try { int _alt; EnterOuterAlt(_localctx, 1); { - State = 193; + State = 236; arithmeticFactor(); - State = 198; + State = 241; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,15,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,19,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { { { - State = 194; + State = 237; _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 144115188075880448L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -1354,14 +1666,14 @@ public ArithmeticTermContext arithmeticTerm() { ErrorHandler.ReportMatch(this); Consume(); } - State = 195; + State = 238; arithmeticFactor(); } } } - State = 200; + State = 243; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,15,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,19,Context); } } } @@ -1420,76 +1732,76 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ArithmeticFactorContext arithmeticFactor() { ArithmeticFactorContext _localctx = new ArithmeticFactorContext(Context, State); - EnterRule(_localctx, 30, RULE_arithmeticFactor); + EnterRule(_localctx, 38, RULE_arithmeticFactor); try { - State = 213; + State = 256; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,16,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,20,Context) ) { case 1: EnterOuterAlt(_localctx, 1); { - State = 201; + State = 244; qualifiedIdent(); } break; case 2: EnterOuterAlt(_localctx, 2); { - State = 202; + State = 245; Match(IDENT); } break; case 3: EnterOuterAlt(_localctx, 3); { - State = 203; + State = 246; Match(INT); } break; case 4: EnterOuterAlt(_localctx, 4); { - State = 204; + State = 247; Match(DECIMAL); } break; case 5: EnterOuterAlt(_localctx, 5); { - State = 205; + State = 248; Match(STRING); } break; case 6: EnterOuterAlt(_localctx, 6); { - State = 206; + State = 249; functionCall(); } break; case 7: EnterOuterAlt(_localctx, 7); { - State = 207; + State = 250; caseExpr(); } break; case 8: EnterOuterAlt(_localctx, 8); { - State = 208; + State = 251; Match(PARAMETER); } break; case 9: EnterOuterAlt(_localctx, 9); { - State = 209; - Match(T__3); - State = 210; + State = 252; + Match(T__1); + State = 253; arithmeticExpr(); - State = 211; - Match(T__4); + State = 254; + Match(T__2); } break; } @@ -1510,6 +1822,10 @@ public partial class FunctionCallContext : ParserRuleContext { [System.Diagnostics.DebuggerNonUserCode] public ArgListContext argList() { return GetRuleContext(0); } + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode OVER() { return GetToken(LqlParser.OVER, 0); } + [System.Diagnostics.DebuggerNonUserCode] public WindowSpecContext windowSpec() { + return GetRuleContext(0); + } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode DISTINCT() { return GetToken(LqlParser.DISTINCT, 0); } public FunctionCallContext(ParserRuleContext parent, int invokingState) : base(parent, invokingState) @@ -1537,37 +1853,53 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public FunctionCallContext functionCall() { FunctionCallContext _localctx = new FunctionCallContext(Context, State); - EnterRule(_localctx, 32, RULE_functionCall); + EnterRule(_localctx, 40, RULE_functionCall); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 215; + State = 258; Match(IDENT); - State = 216; - Match(T__3); - State = 221; + State = 259; + Match(T__1); + State = 264; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179299564560318544L) != 0)) { + if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 179299564560318596L) != 0)) { { - State = 218; + State = 261; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==DISTINCT) { { - State = 217; + State = 260; Match(DISTINCT); } } - State = 220; + State = 263; argList(); } } - State = 223; - Match(T__4); + State = 266; + Match(T__2); + State = 272; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + if (_la==OVER) { + { + State = 267; + Match(OVER); + State = 268; + Match(T__1); + State = 269; + windowSpec(); + State = 270; + Match(T__2); + } + } + } } catch (RecognitionException re) { @@ -1616,12 +1948,12 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public NamedArgContext namedArg() { NamedArgContext _localctx = new NamedArgContext(Context, State); - EnterRule(_localctx, 34, RULE_namedArg); + EnterRule(_localctx, 42, RULE_namedArg); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 225; + State = 274; _la = TokenStream.LA(1); if ( !(_la==ON || _la==IDENT) ) { ErrorHandler.RecoverInline(this); @@ -1630,20 +1962,20 @@ public NamedArgContext namedArg() { ErrorHandler.ReportMatch(this); Consume(); } - State = 226; - Match(T__1); - State = 229; + State = 275; + Match(T__4); + State = 278; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,19,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,24,Context) ) { case 1: { - State = 227; + State = 276; comparison(); } break; case 2: { - State = 228; + State = 277; logicalExpr(); } break; @@ -1698,30 +2030,30 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public LogicalExprContext logicalExpr() { LogicalExprContext _localctx = new LogicalExprContext(Context, State); - EnterRule(_localctx, 36, RULE_logicalExpr); + EnterRule(_localctx, 44, RULE_logicalExpr); try { int _alt; EnterOuterAlt(_localctx, 1); { - State = 231; + State = 280; andExpr(); - State = 236; + State = 285; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,20,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,25,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { { { - State = 232; + State = 281; Match(OR); - State = 233; + State = 282; andExpr(); } } } - State = 238; + State = 287; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,20,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,25,Context); } } } @@ -1773,30 +2105,30 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public AndExprContext andExpr() { AndExprContext _localctx = new AndExprContext(Context, State); - EnterRule(_localctx, 38, RULE_andExpr); + EnterRule(_localctx, 46, RULE_andExpr); try { int _alt; EnterOuterAlt(_localctx, 1); { - State = 239; + State = 288; atomicExpr(); - State = 244; + State = 293; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,21,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,26,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { { { - State = 240; + State = 289; Match(AND); - State = 241; + State = 290; atomicExpr(); } } } - State = 246; + State = 295; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,21,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,26,Context); } } } @@ -1844,27 +2176,27 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public AtomicExprContext atomicExpr() { AtomicExprContext _localctx = new AtomicExprContext(Context, State); - EnterRule(_localctx, 40, RULE_atomicExpr); + EnterRule(_localctx, 48, RULE_atomicExpr); try { - State = 252; + State = 301; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,22,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,27,Context) ) { case 1: EnterOuterAlt(_localctx, 1); { - State = 247; + State = 296; comparison(); } break; case 2: EnterOuterAlt(_localctx, 2); { - State = 248; - Match(T__3); - State = 249; + State = 297; + Match(T__1); + State = 298; logicalExpr(); - State = 250; - Match(T__4); + State = 299; + Match(T__2); } break; } @@ -1948,66 +2280,66 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ComparisonContext comparison() { ComparisonContext _localctx = new ComparisonContext(Context, State); - EnterRule(_localctx, 42, RULE_comparison); + EnterRule(_localctx, 50, RULE_comparison); int _la; try { - State = 307; + State = 356; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,29,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,34,Context) ) { case 1: EnterOuterAlt(_localctx, 1); { - State = 254; + State = 303; arithmeticExpr(); - State = 255; + State = 304; comparisonOp(); - State = 256; + State = 305; arithmeticExpr(); } break; case 2: EnterOuterAlt(_localctx, 2); { - State = 258; + State = 307; qualifiedIdent(); - State = 259; + State = 308; comparisonOp(); - State = 266; + State = 315; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,23,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,28,Context) ) { case 1: { - State = 260; + State = 309; qualifiedIdent(); } break; case 2: { - State = 261; + State = 310; Match(STRING); } break; case 3: { - State = 262; + State = 311; Match(IDENT); } break; case 4: { - State = 263; + State = 312; Match(INT); } break; case 5: { - State = 264; + State = 313; Match(DECIMAL); } break; case 6: { - State = 265; + State = 314; Match(PARAMETER); } break; @@ -2017,46 +2349,46 @@ public ComparisonContext comparison() { case 3: EnterOuterAlt(_localctx, 3); { - State = 268; + State = 317; Match(IDENT); - State = 269; + State = 318; comparisonOp(); - State = 276; + State = 325; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,24,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,29,Context) ) { case 1: { - State = 270; + State = 319; qualifiedIdent(); } break; case 2: { - State = 271; + State = 320; Match(STRING); } break; case 3: { - State = 272; + State = 321; Match(IDENT); } break; case 4: { - State = 273; + State = 322; Match(INT); } break; case 5: { - State = 274; + State = 323; Match(DECIMAL); } break; case 6: { - State = 275; + State = 324; Match(PARAMETER); } break; @@ -2066,46 +2398,46 @@ public ComparisonContext comparison() { case 4: EnterOuterAlt(_localctx, 4); { - State = 278; + State = 327; Match(PARAMETER); - State = 279; + State = 328; comparisonOp(); - State = 286; + State = 335; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,25,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,30,Context) ) { case 1: { - State = 280; + State = 329; qualifiedIdent(); } break; case 2: { - State = 281; + State = 330; Match(STRING); } break; case 3: { - State = 282; + State = 331; Match(IDENT); } break; case 4: { - State = 283; + State = 332; Match(INT); } break; case 5: { - State = 284; + State = 333; Match(DECIMAL); } break; case 6: { - State = 285; + State = 334; Match(PARAMETER); } break; @@ -2115,14 +2447,14 @@ public ComparisonContext comparison() { case 5: EnterOuterAlt(_localctx, 5); { - State = 288; + State = 337; qualifiedIdent(); - State = 290; + State = 339; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 289; + State = 338; orderDirection(); } } @@ -2132,14 +2464,14 @@ public ComparisonContext comparison() { case 6: EnterOuterAlt(_localctx, 6); { - State = 292; + State = 341; Match(IDENT); - State = 294; + State = 343; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 293; + State = 342; orderDirection(); } } @@ -2149,14 +2481,14 @@ public ComparisonContext comparison() { case 7: EnterOuterAlt(_localctx, 7); { - State = 296; + State = 345; Match(PARAMETER); - State = 298; + State = 347; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 297; + State = 346; orderDirection(); } } @@ -2166,49 +2498,49 @@ public ComparisonContext comparison() { case 8: EnterOuterAlt(_localctx, 8); { - State = 300; + State = 349; Match(STRING); } break; case 9: EnterOuterAlt(_localctx, 9); { - State = 301; + State = 350; Match(INT); } break; case 10: EnterOuterAlt(_localctx, 10); { - State = 302; + State = 351; Match(DECIMAL); } break; case 11: EnterOuterAlt(_localctx, 11); { - State = 303; + State = 352; expr(); } break; case 12: EnterOuterAlt(_localctx, 12); { - State = 304; + State = 353; existsExpr(); } break; case 13: EnterOuterAlt(_localctx, 13); { - State = 305; + State = 354; nullCheckExpr(); } break; case 14: EnterOuterAlt(_localctx, 14); { - State = 306; + State = 355; inExpr(); } break; @@ -2256,18 +2588,18 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ExistsExprContext existsExpr() { ExistsExprContext _localctx = new ExistsExprContext(Context, State); - EnterRule(_localctx, 44, RULE_existsExpr); + EnterRule(_localctx, 52, RULE_existsExpr); try { EnterOuterAlt(_localctx, 1); { - State = 309; + State = 358; Match(EXISTS); - State = 310; - Match(T__3); - State = 311; + State = 359; + Match(T__1); + State = 360; pipeExpr(); - State = 312; - Match(T__4); + State = 361; + Match(T__2); } } catch (RecognitionException re) { @@ -2316,47 +2648,47 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public NullCheckExprContext nullCheckExpr() { NullCheckExprContext _localctx = new NullCheckExprContext(Context, State); - EnterRule(_localctx, 46, RULE_nullCheckExpr); + EnterRule(_localctx, 54, RULE_nullCheckExpr); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 317; + State = 366; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,30,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,35,Context) ) { case 1: { - State = 314; + State = 363; qualifiedIdent(); } break; case 2: { - State = 315; + State = 364; Match(IDENT); } break; case 3: { - State = 316; + State = 365; Match(PARAMETER); } break; } { - State = 319; + State = 368; Match(IS); - State = 321; + State = 370; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 320; + State = 369; Match(NOT); } } - State = 323; + State = 372; Match(NULL); } } @@ -2411,54 +2743,54 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public InExprContext inExpr() { InExprContext _localctx = new InExprContext(Context, State); - EnterRule(_localctx, 48, RULE_inExpr); + EnterRule(_localctx, 56, RULE_inExpr); try { EnterOuterAlt(_localctx, 1); { - State = 328; + State = 377; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,32,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,37,Context) ) { case 1: { - State = 325; + State = 374; qualifiedIdent(); } break; case 2: { - State = 326; + State = 375; Match(IDENT); } break; case 3: { - State = 327; + State = 376; Match(PARAMETER); } break; } - State = 330; + State = 379; Match(IN); - State = 331; - Match(T__3); - State = 334; + State = 380; + Match(T__1); + State = 383; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,33,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,38,Context) ) { case 1: { - State = 332; + State = 381; pipeExpr(); } break; case 2: { - State = 333; + State = 382; argList(); } break; } - State = 336; - Match(T__4); + State = 385; + Match(T__2); } } catch (RecognitionException re) { @@ -2511,40 +2843,40 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public CaseExprContext caseExpr() { CaseExprContext _localctx = new CaseExprContext(Context, State); - EnterRule(_localctx, 50, RULE_caseExpr); + EnterRule(_localctx, 58, RULE_caseExpr); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 338; + State = 387; Match(CASE); - State = 340; + State = 389; ErrorHandler.Sync(this); _la = TokenStream.LA(1); do { { { - State = 339; + State = 388; whenClause(); } } - State = 342; + State = 391; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } while ( _la==WHEN ); - State = 346; + State = 395; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ELSE) { { - State = 344; + State = 393; Match(ELSE); - State = 345; + State = 394; caseResult(); } } - State = 348; + State = 397; Match(END); } } @@ -2594,17 +2926,17 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public WhenClauseContext whenClause() { WhenClauseContext _localctx = new WhenClauseContext(Context, State); - EnterRule(_localctx, 52, RULE_whenClause); + EnterRule(_localctx, 60, RULE_whenClause); try { EnterOuterAlt(_localctx, 1); { - State = 350; + State = 399; Match(WHEN); - State = 351; + State = 400; comparison(); - State = 352; + State = 401; Match(THEN); - State = 353; + State = 402; caseResult(); } } @@ -2660,64 +2992,64 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public CaseResultContext caseResult() { CaseResultContext _localctx = new CaseResultContext(Context, State); - EnterRule(_localctx, 54, RULE_caseResult); + EnterRule(_localctx, 62, RULE_caseResult); try { - State = 363; + State = 412; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,36,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,41,Context) ) { case 1: EnterOuterAlt(_localctx, 1); { - State = 355; + State = 404; arithmeticExpr(); } break; case 2: EnterOuterAlt(_localctx, 2); { - State = 356; + State = 405; comparison(); } break; case 3: EnterOuterAlt(_localctx, 3); { - State = 357; + State = 406; qualifiedIdent(); } break; case 4: EnterOuterAlt(_localctx, 4); { - State = 358; + State = 407; Match(IDENT); } break; case 5: EnterOuterAlt(_localctx, 5); { - State = 359; + State = 408; Match(INT); } break; case 6: EnterOuterAlt(_localctx, 6); { - State = 360; + State = 409; Match(DECIMAL); } break; case 7: EnterOuterAlt(_localctx, 7); { - State = 361; + State = 410; Match(STRING); } break; case 8: EnterOuterAlt(_localctx, 8); { - State = 362; + State = 411; Match(PARAMETER); } break; @@ -2763,12 +3095,12 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public OrderDirectionContext orderDirection() { OrderDirectionContext _localctx = new OrderDirectionContext(Context, State); - EnterRule(_localctx, 56, RULE_orderDirection); + EnterRule(_localctx, 64, RULE_orderDirection); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 365; + State = 414; _la = TokenStream.LA(1); if ( !(_la==ASC || _la==DESC) ) { ErrorHandler.RecoverInline(this); @@ -2818,14 +3150,14 @@ public override TResult Accept(IParseTreeVisitor visitor) { [RuleVersion(0)] public ComparisonOpContext comparisonOp() { ComparisonOpContext _localctx = new ComparisonOpContext(Context, State); - EnterRule(_localctx, 58, RULE_comparisonOp); + EnterRule(_localctx, 66, RULE_comparisonOp); int _la; try { EnterOuterAlt(_localctx, 1); { - State = 367; + State = 416; _la = TokenStream.LA(1); - if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 562949955485700L) != 0)) ) { + if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 562949955485728L) != 0)) ) { ErrorHandler.RecoverInline(this); } else { @@ -2846,137 +3178,154 @@ public ComparisonOpContext comparisonOp() { } private static int[] _serializedATN = { - 4,1,57,370,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7, + 4,1,57,419,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7, 7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14, 2,15,7,15,2,16,7,16,2,17,7,17,2,18,7,18,2,19,7,19,2,20,7,20,2,21,7,21, 2,22,7,22,2,23,7,23,2,24,7,24,2,25,7,25,2,26,7,26,2,27,7,27,2,28,7,28, - 2,29,7,29,1,0,5,0,62,8,0,10,0,12,0,65,9,0,1,0,1,0,1,1,1,1,3,1,71,8,1,1, - 2,1,2,1,2,1,2,1,2,1,3,1,3,1,3,5,3,81,8,3,10,3,12,3,84,9,3,1,4,1,4,1,4, - 3,4,89,8,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,3,4,100,8,4,1,4,1,4,1,4, - 1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,3,4,116,8,4,1,5,3,5,119,8, - 5,1,5,3,5,122,8,5,1,6,1,6,1,6,1,6,1,7,1,7,1,7,1,7,1,8,1,8,1,8,1,8,1,8, - 5,8,137,8,8,10,8,12,8,140,9,8,1,8,1,8,1,8,1,8,1,9,1,9,1,9,4,9,149,8,9, - 11,9,12,9,150,1,10,1,10,1,10,5,10,156,8,10,10,10,12,10,159,9,10,1,11,1, - 11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,3,11,174,8,11, - 1,12,1,12,1,12,1,12,3,12,180,8,12,1,12,1,12,3,12,184,8,12,1,13,1,13,1, - 13,5,13,189,8,13,10,13,12,13,192,9,13,1,14,1,14,1,14,5,14,197,8,14,10, - 14,12,14,200,9,14,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1, - 15,1,15,3,15,214,8,15,1,16,1,16,1,16,3,16,219,8,16,1,16,3,16,222,8,16, - 1,16,1,16,1,17,1,17,1,17,1,17,3,17,230,8,17,1,18,1,18,1,18,5,18,235,8, - 18,10,18,12,18,238,9,18,1,19,1,19,1,19,5,19,243,8,19,10,19,12,19,246,9, - 19,1,20,1,20,1,20,1,20,1,20,3,20,253,8,20,1,21,1,21,1,21,1,21,1,21,1,21, - 1,21,1,21,1,21,1,21,1,21,1,21,3,21,267,8,21,1,21,1,21,1,21,1,21,1,21,1, - 21,1,21,1,21,3,21,277,8,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21,3,21, - 287,8,21,1,21,1,21,3,21,291,8,21,1,21,1,21,3,21,295,8,21,1,21,1,21,3,21, - 299,8,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21,3,21,308,8,21,1,22,1,22,1, - 22,1,22,1,22,1,23,1,23,1,23,3,23,318,8,23,1,23,1,23,3,23,322,8,23,1,23, - 1,23,1,24,1,24,1,24,3,24,329,8,24,1,24,1,24,1,24,1,24,3,24,335,8,24,1, - 24,1,24,1,25,1,25,4,25,341,8,25,11,25,12,25,342,1,25,1,25,3,25,347,8,25, - 1,25,1,25,1,26,1,26,1,26,1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27, - 1,27,3,27,364,8,27,1,28,1,28,1,29,1,29,1,29,0,0,30,0,2,4,6,8,10,12,14, - 16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,0,5, - 1,0,10,12,2,0,13,14,57,57,2,0,48,48,51,51,1,0,21,22,3,0,2,2,15,20,49,49, - 435,0,63,1,0,0,0,2,70,1,0,0,0,4,72,1,0,0,0,6,77,1,0,0,0,8,115,1,0,0,0, - 10,118,1,0,0,0,12,123,1,0,0,0,14,127,1,0,0,0,16,131,1,0,0,0,18,145,1,0, - 0,0,20,152,1,0,0,0,22,173,1,0,0,0,24,179,1,0,0,0,26,185,1,0,0,0,28,193, - 1,0,0,0,30,213,1,0,0,0,32,215,1,0,0,0,34,225,1,0,0,0,36,231,1,0,0,0,38, - 239,1,0,0,0,40,252,1,0,0,0,42,307,1,0,0,0,44,309,1,0,0,0,46,317,1,0,0, - 0,48,328,1,0,0,0,50,338,1,0,0,0,52,350,1,0,0,0,54,363,1,0,0,0,56,365,1, - 0,0,0,58,367,1,0,0,0,60,62,3,2,1,0,61,60,1,0,0,0,62,65,1,0,0,0,63,61,1, - 0,0,0,63,64,1,0,0,0,64,66,1,0,0,0,65,63,1,0,0,0,66,67,5,0,0,1,67,1,1,0, - 0,0,68,71,3,4,2,0,69,71,3,6,3,0,70,68,1,0,0,0,70,69,1,0,0,0,71,3,1,0,0, - 0,72,73,5,1,0,0,73,74,5,51,0,0,74,75,5,2,0,0,75,76,3,6,3,0,76,5,1,0,0, - 0,77,82,3,8,4,0,78,79,5,3,0,0,79,81,3,8,4,0,80,78,1,0,0,0,81,84,1,0,0, - 0,82,80,1,0,0,0,82,83,1,0,0,0,83,7,1,0,0,0,84,82,1,0,0,0,85,86,5,51,0, - 0,86,88,5,4,0,0,87,89,3,20,10,0,88,87,1,0,0,0,88,89,1,0,0,0,89,90,1,0, - 0,0,90,91,5,5,0,0,91,92,5,38,0,0,92,93,5,4,0,0,93,94,3,10,5,0,94,95,5, - 5,0,0,95,116,1,0,0,0,96,97,5,51,0,0,97,99,5,4,0,0,98,100,3,20,10,0,99, - 98,1,0,0,0,99,100,1,0,0,0,100,101,1,0,0,0,101,116,5,5,0,0,102,116,5,51, - 0,0,103,104,5,4,0,0,104,105,3,6,3,0,105,106,5,5,0,0,106,116,1,0,0,0,107, - 116,3,18,9,0,108,116,3,16,8,0,109,116,3,50,25,0,110,116,5,52,0,0,111,116, - 5,53,0,0,112,116,5,57,0,0,113,116,5,54,0,0,114,116,5,50,0,0,115,85,1,0, - 0,0,115,96,1,0,0,0,115,102,1,0,0,0,115,103,1,0,0,0,115,107,1,0,0,0,115, - 108,1,0,0,0,115,109,1,0,0,0,115,110,1,0,0,0,115,111,1,0,0,0,115,112,1, - 0,0,0,115,113,1,0,0,0,115,114,1,0,0,0,116,9,1,0,0,0,117,119,3,12,6,0,118, - 117,1,0,0,0,118,119,1,0,0,0,119,121,1,0,0,0,120,122,3,14,7,0,121,120,1, - 0,0,0,121,122,1,0,0,0,122,11,1,0,0,0,123,124,5,39,0,0,124,125,5,41,0,0, - 125,126,3,20,10,0,126,13,1,0,0,0,127,128,5,40,0,0,128,129,5,41,0,0,129, - 130,3,20,10,0,130,15,1,0,0,0,131,132,5,6,0,0,132,133,5,4,0,0,133,138,5, - 51,0,0,134,135,5,7,0,0,135,137,5,51,0,0,136,134,1,0,0,0,137,140,1,0,0, - 0,138,136,1,0,0,0,138,139,1,0,0,0,139,141,1,0,0,0,140,138,1,0,0,0,141, - 142,5,5,0,0,142,143,5,8,0,0,143,144,3,36,18,0,144,17,1,0,0,0,145,148,5, - 51,0,0,146,147,5,9,0,0,147,149,5,51,0,0,148,146,1,0,0,0,149,150,1,0,0, - 0,150,148,1,0,0,0,150,151,1,0,0,0,151,19,1,0,0,0,152,157,3,22,11,0,153, - 154,5,7,0,0,154,156,3,22,11,0,155,153,1,0,0,0,156,159,1,0,0,0,157,155, - 1,0,0,0,157,158,1,0,0,0,158,21,1,0,0,0,159,157,1,0,0,0,160,174,3,24,12, - 0,161,174,3,26,13,0,162,174,3,32,16,0,163,174,3,50,25,0,164,174,3,8,4, - 0,165,174,3,34,17,0,166,174,3,42,21,0,167,174,3,6,3,0,168,174,3,16,8,0, - 169,170,5,4,0,0,170,171,3,6,3,0,171,172,5,5,0,0,172,174,1,0,0,0,173,160, - 1,0,0,0,173,161,1,0,0,0,173,162,1,0,0,0,173,163,1,0,0,0,173,164,1,0,0, - 0,173,165,1,0,0,0,173,166,1,0,0,0,173,167,1,0,0,0,173,168,1,0,0,0,173, - 169,1,0,0,0,174,23,1,0,0,0,175,180,3,26,13,0,176,180,3,32,16,0,177,180, - 3,18,9,0,178,180,5,51,0,0,179,175,1,0,0,0,179,176,1,0,0,0,179,177,1,0, - 0,0,179,178,1,0,0,0,180,183,1,0,0,0,181,182,5,31,0,0,182,184,5,51,0,0, - 183,181,1,0,0,0,183,184,1,0,0,0,184,25,1,0,0,0,185,190,3,28,14,0,186,187, - 7,0,0,0,187,189,3,28,14,0,188,186,1,0,0,0,189,192,1,0,0,0,190,188,1,0, - 0,0,190,191,1,0,0,0,191,27,1,0,0,0,192,190,1,0,0,0,193,198,3,30,15,0,194, - 195,7,1,0,0,195,197,3,30,15,0,196,194,1,0,0,0,197,200,1,0,0,0,198,196, - 1,0,0,0,198,199,1,0,0,0,199,29,1,0,0,0,200,198,1,0,0,0,201,214,3,18,9, - 0,202,214,5,51,0,0,203,214,5,52,0,0,204,214,5,53,0,0,205,214,5,54,0,0, - 206,214,3,32,16,0,207,214,3,50,25,0,208,214,5,50,0,0,209,210,5,4,0,0,210, - 211,3,26,13,0,211,212,5,5,0,0,212,214,1,0,0,0,213,201,1,0,0,0,213,202, - 1,0,0,0,213,203,1,0,0,0,213,204,1,0,0,0,213,205,1,0,0,0,213,206,1,0,0, - 0,213,207,1,0,0,0,213,208,1,0,0,0,213,209,1,0,0,0,214,31,1,0,0,0,215,216, - 5,51,0,0,216,221,5,4,0,0,217,219,5,25,0,0,218,217,1,0,0,0,218,219,1,0, - 0,0,219,220,1,0,0,0,220,222,3,20,10,0,221,218,1,0,0,0,221,222,1,0,0,0, - 222,223,1,0,0,0,223,224,5,5,0,0,224,33,1,0,0,0,225,226,7,2,0,0,226,229, - 5,2,0,0,227,230,3,42,21,0,228,230,3,36,18,0,229,227,1,0,0,0,229,228,1, - 0,0,0,230,35,1,0,0,0,231,236,3,38,19,0,232,233,5,24,0,0,233,235,3,38,19, - 0,234,232,1,0,0,0,235,238,1,0,0,0,236,234,1,0,0,0,236,237,1,0,0,0,237, - 37,1,0,0,0,238,236,1,0,0,0,239,244,3,40,20,0,240,241,5,23,0,0,241,243, - 3,40,20,0,242,240,1,0,0,0,243,246,1,0,0,0,244,242,1,0,0,0,244,245,1,0, - 0,0,245,39,1,0,0,0,246,244,1,0,0,0,247,253,3,42,21,0,248,249,5,4,0,0,249, - 250,3,36,18,0,250,251,5,5,0,0,251,253,1,0,0,0,252,247,1,0,0,0,252,248, - 1,0,0,0,253,41,1,0,0,0,254,255,3,26,13,0,255,256,3,58,29,0,256,257,3,26, - 13,0,257,308,1,0,0,0,258,259,3,18,9,0,259,266,3,58,29,0,260,267,3,18,9, - 0,261,267,5,54,0,0,262,267,5,51,0,0,263,267,5,52,0,0,264,267,5,53,0,0, - 265,267,5,50,0,0,266,260,1,0,0,0,266,261,1,0,0,0,266,262,1,0,0,0,266,263, - 1,0,0,0,266,264,1,0,0,0,266,265,1,0,0,0,267,308,1,0,0,0,268,269,5,51,0, - 0,269,276,3,58,29,0,270,277,3,18,9,0,271,277,5,54,0,0,272,277,5,51,0,0, - 273,277,5,52,0,0,274,277,5,53,0,0,275,277,5,50,0,0,276,270,1,0,0,0,276, - 271,1,0,0,0,276,272,1,0,0,0,276,273,1,0,0,0,276,274,1,0,0,0,276,275,1, - 0,0,0,277,308,1,0,0,0,278,279,5,50,0,0,279,286,3,58,29,0,280,287,3,18, - 9,0,281,287,5,54,0,0,282,287,5,51,0,0,283,287,5,52,0,0,284,287,5,53,0, - 0,285,287,5,50,0,0,286,280,1,0,0,0,286,281,1,0,0,0,286,282,1,0,0,0,286, - 283,1,0,0,0,286,284,1,0,0,0,286,285,1,0,0,0,287,308,1,0,0,0,288,290,3, - 18,9,0,289,291,3,56,28,0,290,289,1,0,0,0,290,291,1,0,0,0,291,308,1,0,0, - 0,292,294,5,51,0,0,293,295,3,56,28,0,294,293,1,0,0,0,294,295,1,0,0,0,295, - 308,1,0,0,0,296,298,5,50,0,0,297,299,3,56,28,0,298,297,1,0,0,0,298,299, - 1,0,0,0,299,308,1,0,0,0,300,308,5,54,0,0,301,308,5,52,0,0,302,308,5,53, - 0,0,303,308,3,8,4,0,304,308,3,44,22,0,305,308,3,46,23,0,306,308,3,48,24, - 0,307,254,1,0,0,0,307,258,1,0,0,0,307,268,1,0,0,0,307,278,1,0,0,0,307, - 288,1,0,0,0,307,292,1,0,0,0,307,296,1,0,0,0,307,300,1,0,0,0,307,301,1, - 0,0,0,307,302,1,0,0,0,307,303,1,0,0,0,307,304,1,0,0,0,307,305,1,0,0,0, - 307,306,1,0,0,0,308,43,1,0,0,0,309,310,5,26,0,0,310,311,5,4,0,0,311,312, - 3,6,3,0,312,313,5,5,0,0,313,45,1,0,0,0,314,318,3,18,9,0,315,318,5,51,0, - 0,316,318,5,50,0,0,317,314,1,0,0,0,317,315,1,0,0,0,317,316,1,0,0,0,318, - 319,1,0,0,0,319,321,5,28,0,0,320,322,5,29,0,0,321,320,1,0,0,0,321,322, - 1,0,0,0,322,323,1,0,0,0,323,324,5,27,0,0,324,47,1,0,0,0,325,329,3,18,9, - 0,326,329,5,51,0,0,327,329,5,50,0,0,328,325,1,0,0,0,328,326,1,0,0,0,328, - 327,1,0,0,0,329,330,1,0,0,0,330,331,5,30,0,0,331,334,5,4,0,0,332,335,3, - 6,3,0,333,335,3,20,10,0,334,332,1,0,0,0,334,333,1,0,0,0,335,336,1,0,0, - 0,336,337,5,5,0,0,337,49,1,0,0,0,338,340,5,32,0,0,339,341,3,52,26,0,340, - 339,1,0,0,0,341,342,1,0,0,0,342,340,1,0,0,0,342,343,1,0,0,0,343,346,1, - 0,0,0,344,345,5,35,0,0,345,347,3,54,27,0,346,344,1,0,0,0,346,347,1,0,0, - 0,347,348,1,0,0,0,348,349,5,36,0,0,349,51,1,0,0,0,350,351,5,33,0,0,351, - 352,3,42,21,0,352,353,5,34,0,0,353,354,3,54,27,0,354,53,1,0,0,0,355,364, - 3,26,13,0,356,364,3,42,21,0,357,364,3,18,9,0,358,364,5,51,0,0,359,364, - 5,52,0,0,360,364,5,53,0,0,361,364,5,54,0,0,362,364,5,50,0,0,363,355,1, - 0,0,0,363,356,1,0,0,0,363,357,1,0,0,0,363,358,1,0,0,0,363,359,1,0,0,0, - 363,360,1,0,0,0,363,361,1,0,0,0,363,362,1,0,0,0,364,55,1,0,0,0,365,366, - 7,3,0,0,366,57,1,0,0,0,367,368,7,4,0,0,368,59,1,0,0,0,37,63,70,82,88,99, - 115,118,121,138,150,157,173,179,183,190,198,213,218,221,229,236,244,252, - 266,276,286,290,294,298,307,317,321,328,334,342,346,363 + 2,29,7,29,2,30,7,30,2,31,7,31,2,32,7,32,2,33,7,33,1,0,5,0,70,8,0,10,0, + 12,0,73,9,0,1,0,1,0,1,1,1,1,1,1,3,1,80,8,1,1,2,1,2,1,2,1,2,5,2,86,8,2, + 10,2,12,2,89,9,2,1,2,1,2,1,3,1,3,1,3,1,3,1,3,1,3,1,4,1,4,1,4,1,4,1,4,1, + 5,1,5,1,5,5,5,107,8,5,10,5,12,5,110,9,5,1,6,1,6,1,6,3,6,115,8,6,1,6,1, + 6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,3,6,126,8,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6, + 1,6,1,6,1,6,1,6,1,6,1,6,1,6,3,6,142,8,6,1,7,3,7,145,8,7,1,7,3,7,148,8, + 7,1,8,1,8,1,8,1,8,1,9,1,9,1,9,1,9,1,10,1,10,1,10,5,10,161,8,10,10,10,12, + 10,164,9,10,1,11,1,11,1,11,1,11,3,11,170,8,11,1,11,3,11,173,8,11,1,12, + 1,12,1,12,1,12,1,12,5,12,180,8,12,10,12,12,12,183,9,12,1,12,1,12,1,12, + 1,12,1,13,1,13,1,13,4,13,192,8,13,11,13,12,13,193,1,14,1,14,1,14,5,14, + 199,8,14,10,14,12,14,202,9,14,1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,15, + 1,15,1,15,1,15,1,15,1,15,3,15,217,8,15,1,16,1,16,1,16,1,16,3,16,223,8, + 16,1,16,1,16,3,16,227,8,16,1,17,1,17,1,17,5,17,232,8,17,10,17,12,17,235, + 9,17,1,18,1,18,1,18,5,18,240,8,18,10,18,12,18,243,9,18,1,19,1,19,1,19, + 1,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,3,19,257,8,19,1,20,1,20,1, + 20,3,20,262,8,20,1,20,3,20,265,8,20,1,20,1,20,1,20,1,20,1,20,1,20,3,20, + 273,8,20,1,21,1,21,1,21,1,21,3,21,279,8,21,1,22,1,22,1,22,5,22,284,8,22, + 10,22,12,22,287,9,22,1,23,1,23,1,23,5,23,292,8,23,10,23,12,23,295,9,23, + 1,24,1,24,1,24,1,24,1,24,3,24,302,8,24,1,25,1,25,1,25,1,25,1,25,1,25,1, + 25,1,25,1,25,1,25,1,25,1,25,3,25,316,8,25,1,25,1,25,1,25,1,25,1,25,1,25, + 1,25,1,25,3,25,326,8,25,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,25,3,25,336, + 8,25,1,25,1,25,3,25,340,8,25,1,25,1,25,3,25,344,8,25,1,25,1,25,3,25,348, + 8,25,1,25,1,25,1,25,1,25,1,25,1,25,1,25,3,25,357,8,25,1,26,1,26,1,26,1, + 26,1,26,1,27,1,27,1,27,3,27,367,8,27,1,27,1,27,3,27,371,8,27,1,27,1,27, + 1,28,1,28,1,28,3,28,378,8,28,1,28,1,28,1,28,1,28,3,28,384,8,28,1,28,1, + 28,1,29,1,29,4,29,390,8,29,11,29,12,29,391,1,29,1,29,3,29,396,8,29,1,29, + 1,29,1,30,1,30,1,30,1,30,1,30,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31, + 3,31,413,8,31,1,32,1,32,1,33,1,33,1,33,0,0,34,0,2,4,6,8,10,12,14,16,18, + 20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66, + 0,5,1,0,10,12,2,0,13,14,57,57,2,0,48,48,51,51,1,0,21,22,3,0,5,5,15,20, + 49,49,488,0,71,1,0,0,0,2,79,1,0,0,0,4,81,1,0,0,0,6,92,1,0,0,0,8,98,1,0, + 0,0,10,103,1,0,0,0,12,141,1,0,0,0,14,144,1,0,0,0,16,149,1,0,0,0,18,153, + 1,0,0,0,20,157,1,0,0,0,22,169,1,0,0,0,24,174,1,0,0,0,26,188,1,0,0,0,28, + 195,1,0,0,0,30,216,1,0,0,0,32,222,1,0,0,0,34,228,1,0,0,0,36,236,1,0,0, + 0,38,256,1,0,0,0,40,258,1,0,0,0,42,274,1,0,0,0,44,280,1,0,0,0,46,288,1, + 0,0,0,48,301,1,0,0,0,50,356,1,0,0,0,52,358,1,0,0,0,54,366,1,0,0,0,56,377, + 1,0,0,0,58,387,1,0,0,0,60,399,1,0,0,0,62,412,1,0,0,0,64,414,1,0,0,0,66, + 416,1,0,0,0,68,70,3,2,1,0,69,68,1,0,0,0,70,73,1,0,0,0,71,69,1,0,0,0,71, + 72,1,0,0,0,72,74,1,0,0,0,73,71,1,0,0,0,74,75,5,0,0,1,75,1,1,0,0,0,76,80, + 3,4,2,0,77,80,3,8,4,0,78,80,3,10,5,0,79,76,1,0,0,0,79,77,1,0,0,0,79,78, + 1,0,0,0,80,3,1,0,0,0,81,82,5,37,0,0,82,87,3,6,3,0,83,84,5,1,0,0,84,86, + 3,6,3,0,85,83,1,0,0,0,86,89,1,0,0,0,87,85,1,0,0,0,87,88,1,0,0,0,88,90, + 1,0,0,0,89,87,1,0,0,0,90,91,3,10,5,0,91,5,1,0,0,0,92,93,5,51,0,0,93,94, + 5,31,0,0,94,95,5,2,0,0,95,96,3,10,5,0,96,97,5,3,0,0,97,7,1,0,0,0,98,99, + 5,4,0,0,99,100,5,51,0,0,100,101,5,5,0,0,101,102,3,10,5,0,102,9,1,0,0,0, + 103,108,3,12,6,0,104,105,5,6,0,0,105,107,3,12,6,0,106,104,1,0,0,0,107, + 110,1,0,0,0,108,106,1,0,0,0,108,109,1,0,0,0,109,11,1,0,0,0,110,108,1,0, + 0,0,111,112,5,51,0,0,112,114,5,2,0,0,113,115,3,28,14,0,114,113,1,0,0,0, + 114,115,1,0,0,0,115,116,1,0,0,0,116,117,5,3,0,0,117,118,5,38,0,0,118,119, + 5,2,0,0,119,120,3,14,7,0,120,121,5,3,0,0,121,142,1,0,0,0,122,123,5,51, + 0,0,123,125,5,2,0,0,124,126,3,28,14,0,125,124,1,0,0,0,125,126,1,0,0,0, + 126,127,1,0,0,0,127,142,5,3,0,0,128,142,5,51,0,0,129,130,5,2,0,0,130,131, + 3,10,5,0,131,132,5,3,0,0,132,142,1,0,0,0,133,142,3,26,13,0,134,142,3,24, + 12,0,135,142,3,58,29,0,136,142,5,52,0,0,137,142,5,53,0,0,138,142,5,57, + 0,0,139,142,5,54,0,0,140,142,5,50,0,0,141,111,1,0,0,0,141,122,1,0,0,0, + 141,128,1,0,0,0,141,129,1,0,0,0,141,133,1,0,0,0,141,134,1,0,0,0,141,135, + 1,0,0,0,141,136,1,0,0,0,141,137,1,0,0,0,141,138,1,0,0,0,141,139,1,0,0, + 0,141,140,1,0,0,0,142,13,1,0,0,0,143,145,3,16,8,0,144,143,1,0,0,0,144, + 145,1,0,0,0,145,147,1,0,0,0,146,148,3,18,9,0,147,146,1,0,0,0,147,148,1, + 0,0,0,148,15,1,0,0,0,149,150,5,39,0,0,150,151,5,41,0,0,151,152,3,28,14, + 0,152,17,1,0,0,0,153,154,5,40,0,0,154,155,5,41,0,0,155,156,3,20,10,0,156, + 19,1,0,0,0,157,162,3,22,11,0,158,159,5,1,0,0,159,161,3,22,11,0,160,158, + 1,0,0,0,161,164,1,0,0,0,162,160,1,0,0,0,162,163,1,0,0,0,163,21,1,0,0,0, + 164,162,1,0,0,0,165,170,3,34,17,0,166,170,3,40,20,0,167,170,3,26,13,0, + 168,170,5,51,0,0,169,165,1,0,0,0,169,166,1,0,0,0,169,167,1,0,0,0,169,168, + 1,0,0,0,170,172,1,0,0,0,171,173,3,64,32,0,172,171,1,0,0,0,172,173,1,0, + 0,0,173,23,1,0,0,0,174,175,5,7,0,0,175,176,5,2,0,0,176,181,5,51,0,0,177, + 178,5,1,0,0,178,180,5,51,0,0,179,177,1,0,0,0,180,183,1,0,0,0,181,179,1, + 0,0,0,181,182,1,0,0,0,182,184,1,0,0,0,183,181,1,0,0,0,184,185,5,3,0,0, + 185,186,5,8,0,0,186,187,3,44,22,0,187,25,1,0,0,0,188,191,5,51,0,0,189, + 190,5,9,0,0,190,192,5,51,0,0,191,189,1,0,0,0,192,193,1,0,0,0,193,191,1, + 0,0,0,193,194,1,0,0,0,194,27,1,0,0,0,195,200,3,30,15,0,196,197,5,1,0,0, + 197,199,3,30,15,0,198,196,1,0,0,0,199,202,1,0,0,0,200,198,1,0,0,0,200, + 201,1,0,0,0,201,29,1,0,0,0,202,200,1,0,0,0,203,217,3,32,16,0,204,217,3, + 34,17,0,205,217,3,40,20,0,206,217,3,58,29,0,207,217,3,12,6,0,208,217,3, + 42,21,0,209,217,3,50,25,0,210,217,3,10,5,0,211,217,3,24,12,0,212,213,5, + 2,0,0,213,214,3,10,5,0,214,215,5,3,0,0,215,217,1,0,0,0,216,203,1,0,0,0, + 216,204,1,0,0,0,216,205,1,0,0,0,216,206,1,0,0,0,216,207,1,0,0,0,216,208, + 1,0,0,0,216,209,1,0,0,0,216,210,1,0,0,0,216,211,1,0,0,0,216,212,1,0,0, + 0,217,31,1,0,0,0,218,223,3,34,17,0,219,223,3,40,20,0,220,223,3,26,13,0, + 221,223,5,51,0,0,222,218,1,0,0,0,222,219,1,0,0,0,222,220,1,0,0,0,222,221, + 1,0,0,0,223,226,1,0,0,0,224,225,5,31,0,0,225,227,5,51,0,0,226,224,1,0, + 0,0,226,227,1,0,0,0,227,33,1,0,0,0,228,233,3,36,18,0,229,230,7,0,0,0,230, + 232,3,36,18,0,231,229,1,0,0,0,232,235,1,0,0,0,233,231,1,0,0,0,233,234, + 1,0,0,0,234,35,1,0,0,0,235,233,1,0,0,0,236,241,3,38,19,0,237,238,7,1,0, + 0,238,240,3,38,19,0,239,237,1,0,0,0,240,243,1,0,0,0,241,239,1,0,0,0,241, + 242,1,0,0,0,242,37,1,0,0,0,243,241,1,0,0,0,244,257,3,26,13,0,245,257,5, + 51,0,0,246,257,5,52,0,0,247,257,5,53,0,0,248,257,5,54,0,0,249,257,3,40, + 20,0,250,257,3,58,29,0,251,257,5,50,0,0,252,253,5,2,0,0,253,254,3,34,17, + 0,254,255,5,3,0,0,255,257,1,0,0,0,256,244,1,0,0,0,256,245,1,0,0,0,256, + 246,1,0,0,0,256,247,1,0,0,0,256,248,1,0,0,0,256,249,1,0,0,0,256,250,1, + 0,0,0,256,251,1,0,0,0,256,252,1,0,0,0,257,39,1,0,0,0,258,259,5,51,0,0, + 259,264,5,2,0,0,260,262,5,25,0,0,261,260,1,0,0,0,261,262,1,0,0,0,262,263, + 1,0,0,0,263,265,3,28,14,0,264,261,1,0,0,0,264,265,1,0,0,0,265,266,1,0, + 0,0,266,272,5,3,0,0,267,268,5,38,0,0,268,269,5,2,0,0,269,270,3,14,7,0, + 270,271,5,3,0,0,271,273,1,0,0,0,272,267,1,0,0,0,272,273,1,0,0,0,273,41, + 1,0,0,0,274,275,7,2,0,0,275,278,5,5,0,0,276,279,3,50,25,0,277,279,3,44, + 22,0,278,276,1,0,0,0,278,277,1,0,0,0,279,43,1,0,0,0,280,285,3,46,23,0, + 281,282,5,24,0,0,282,284,3,46,23,0,283,281,1,0,0,0,284,287,1,0,0,0,285, + 283,1,0,0,0,285,286,1,0,0,0,286,45,1,0,0,0,287,285,1,0,0,0,288,293,3,48, + 24,0,289,290,5,23,0,0,290,292,3,48,24,0,291,289,1,0,0,0,292,295,1,0,0, + 0,293,291,1,0,0,0,293,294,1,0,0,0,294,47,1,0,0,0,295,293,1,0,0,0,296,302, + 3,50,25,0,297,298,5,2,0,0,298,299,3,44,22,0,299,300,5,3,0,0,300,302,1, + 0,0,0,301,296,1,0,0,0,301,297,1,0,0,0,302,49,1,0,0,0,303,304,3,34,17,0, + 304,305,3,66,33,0,305,306,3,34,17,0,306,357,1,0,0,0,307,308,3,26,13,0, + 308,315,3,66,33,0,309,316,3,26,13,0,310,316,5,54,0,0,311,316,5,51,0,0, + 312,316,5,52,0,0,313,316,5,53,0,0,314,316,5,50,0,0,315,309,1,0,0,0,315, + 310,1,0,0,0,315,311,1,0,0,0,315,312,1,0,0,0,315,313,1,0,0,0,315,314,1, + 0,0,0,316,357,1,0,0,0,317,318,5,51,0,0,318,325,3,66,33,0,319,326,3,26, + 13,0,320,326,5,54,0,0,321,326,5,51,0,0,322,326,5,52,0,0,323,326,5,53,0, + 0,324,326,5,50,0,0,325,319,1,0,0,0,325,320,1,0,0,0,325,321,1,0,0,0,325, + 322,1,0,0,0,325,323,1,0,0,0,325,324,1,0,0,0,326,357,1,0,0,0,327,328,5, + 50,0,0,328,335,3,66,33,0,329,336,3,26,13,0,330,336,5,54,0,0,331,336,5, + 51,0,0,332,336,5,52,0,0,333,336,5,53,0,0,334,336,5,50,0,0,335,329,1,0, + 0,0,335,330,1,0,0,0,335,331,1,0,0,0,335,332,1,0,0,0,335,333,1,0,0,0,335, + 334,1,0,0,0,336,357,1,0,0,0,337,339,3,26,13,0,338,340,3,64,32,0,339,338, + 1,0,0,0,339,340,1,0,0,0,340,357,1,0,0,0,341,343,5,51,0,0,342,344,3,64, + 32,0,343,342,1,0,0,0,343,344,1,0,0,0,344,357,1,0,0,0,345,347,5,50,0,0, + 346,348,3,64,32,0,347,346,1,0,0,0,347,348,1,0,0,0,348,357,1,0,0,0,349, + 357,5,54,0,0,350,357,5,52,0,0,351,357,5,53,0,0,352,357,3,12,6,0,353,357, + 3,52,26,0,354,357,3,54,27,0,355,357,3,56,28,0,356,303,1,0,0,0,356,307, + 1,0,0,0,356,317,1,0,0,0,356,327,1,0,0,0,356,337,1,0,0,0,356,341,1,0,0, + 0,356,345,1,0,0,0,356,349,1,0,0,0,356,350,1,0,0,0,356,351,1,0,0,0,356, + 352,1,0,0,0,356,353,1,0,0,0,356,354,1,0,0,0,356,355,1,0,0,0,357,51,1,0, + 0,0,358,359,5,26,0,0,359,360,5,2,0,0,360,361,3,10,5,0,361,362,5,3,0,0, + 362,53,1,0,0,0,363,367,3,26,13,0,364,367,5,51,0,0,365,367,5,50,0,0,366, + 363,1,0,0,0,366,364,1,0,0,0,366,365,1,0,0,0,367,368,1,0,0,0,368,370,5, + 28,0,0,369,371,5,29,0,0,370,369,1,0,0,0,370,371,1,0,0,0,371,372,1,0,0, + 0,372,373,5,27,0,0,373,55,1,0,0,0,374,378,3,26,13,0,375,378,5,51,0,0,376, + 378,5,50,0,0,377,374,1,0,0,0,377,375,1,0,0,0,377,376,1,0,0,0,378,379,1, + 0,0,0,379,380,5,30,0,0,380,383,5,2,0,0,381,384,3,10,5,0,382,384,3,28,14, + 0,383,381,1,0,0,0,383,382,1,0,0,0,384,385,1,0,0,0,385,386,5,3,0,0,386, + 57,1,0,0,0,387,389,5,32,0,0,388,390,3,60,30,0,389,388,1,0,0,0,390,391, + 1,0,0,0,391,389,1,0,0,0,391,392,1,0,0,0,392,395,1,0,0,0,393,394,5,35,0, + 0,394,396,3,62,31,0,395,393,1,0,0,0,395,396,1,0,0,0,396,397,1,0,0,0,397, + 398,5,36,0,0,398,59,1,0,0,0,399,400,5,33,0,0,400,401,3,50,25,0,401,402, + 5,34,0,0,402,403,3,62,31,0,403,61,1,0,0,0,404,413,3,34,17,0,405,413,3, + 50,25,0,406,413,3,26,13,0,407,413,5,51,0,0,408,413,5,52,0,0,409,413,5, + 53,0,0,410,413,5,54,0,0,411,413,5,50,0,0,412,404,1,0,0,0,412,405,1,0,0, + 0,412,406,1,0,0,0,412,407,1,0,0,0,412,408,1,0,0,0,412,409,1,0,0,0,412, + 410,1,0,0,0,412,411,1,0,0,0,413,63,1,0,0,0,414,415,7,3,0,0,415,65,1,0, + 0,0,416,417,7,4,0,0,417,67,1,0,0,0,42,71,79,87,108,114,125,141,144,147, + 162,169,172,181,193,200,216,222,226,233,241,256,261,264,272,278,285,293, + 301,315,325,335,339,343,347,356,366,370,377,383,391,395,412 }; public static readonly ATN _ATN = diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs index d79f85f1..36cf228f 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs @@ -85,6 +85,11 @@ public override INode VisitProgram([NotNull] LqlParser.ProgramContext context) /// The statement's AST node. public override INode VisitStatement([NotNull] LqlParser.StatementContext context) { + if (context.withStmt() != null) + { + return Visit(context.withStmt()); + } + if (context.letStmt() != null) { return Visit(context.letStmt()); @@ -98,6 +103,37 @@ public override INode VisitStatement([NotNull] LqlParser.StatementContext contex throw new SqlErrorException(CreateSqlError("Unknown statement type", context)); } + /// + /// Visits with name as (pipeline), ... main. Implements [LQL-CTE]. + /// + /// The with statement context. + /// The statement with its common table expressions. + public override INode VisitWithStmt([NotNull] LqlParser.WithStmtContext context) => + new WithQuery( + [ + .. context + .cteDef() + .Select(cte => new CommonTableExpression( + cte.IDENT().GetText(), + AsPipeline(Visit(cte.pipeExpr()), cte) + )), + ], + AsPipeline(Visit(context.pipeExpr()), context) + ); + + /// + /// Wraps a bare table reference in a one-step pipeline. + /// + private static Pipeline AsPipeline(INode node, ParserRuleContext context) => + node switch + { + Pipeline pipeline => pipeline, + Identifier table => new Pipeline { Steps = { new IdentityStep { Base = table } } }, + _ => throw new SqlErrorException( + CreateSqlErrorStatic("Expected a pipeline or table name", context) + ), + }; + /// /// Visits a let statement and stores the variable. /// @@ -255,7 +291,7 @@ public override INode VisitLambdaExpr([NotNull] LqlParser.LambdaExprContext cont if (logicalExpr != null) { // Process the logical expression with lambda variable scope - string conditionText = ProcessLambdaLogicalExpr(logicalExpr, parameters); + string conditionText = ProcessLambdaLogicalExpr(logicalExpr, parameters, _lambdaScope); return new Identifier(conditionText); } @@ -270,16 +306,20 @@ public override INode VisitLambdaExpr([NotNull] LqlParser.LambdaExprContext cont /// /// The logical expression context. /// The lambda variable names. + /// Variables from any enclosing lambda. /// The processed condition text. private static string ProcessLambdaLogicalExpr( LqlParser.LogicalExprContext logicalExpr, - List lambdaVariables + List lambdaVariables, + HashSet? inheritedScope = null ) { - // Create a new visitor instance with lambda variable scope + // Create a new visitor instance with lambda variable scope. Variables + // from an enclosing lambda (e.g. `row` in correlated subqueries) + // remain visible so their references resolve to the outer tables. var visitor = new LqlToAstVisitor(); visitor._lambdaScope = new HashSet( - lambdaVariables, + lambdaVariables.Concat(inheritedScope ?? []), StringComparer.OrdinalIgnoreCase ); @@ -433,6 +473,15 @@ expr as ParserRuleContext return $"{left} IS {(isNot ? "NOT " : string.Empty)}NULL"; } + // Handle EXISTS (subquery) predicates. + if (comparison.existsExpr() != null) + { + return FormatSubquery( + "EXISTS", + ProcessSubqueryToSql(comparison.existsExpr().pipeExpr(), lambdaScope) + ); + } + if (comparison.inExpr() != null) { return ProcessInExpressionToSql(comparison.inExpr(), lambdaScope); @@ -452,6 +501,14 @@ private static string ProcessInExpressionToSql( HashSet? lambdaScope ) { + // Implements [LQL-PREDICATE-IN-LIST] and [LQL-PREDICATE-IN-SUBQUERY]: + // `x in (pipeline |> ...)` becomes `x IN (SELECT ...)`. + if (inExpr.pipeExpr() is { } inSubquery) + { + var subqueryLeft = ProcessInLeftExpressionToSql(inExpr, lambdaScope); + return $"{subqueryLeft} {FormatSubquery("IN", ProcessSubqueryToSql(inSubquery, lambdaScope))}"; + } + // Implements [LQL-PREDICATE-IN-LIST]. if (inExpr.argList() == null) { @@ -465,6 +522,55 @@ private static string ProcessInExpressionToSql( return $"{left} IN ({string.Join(", ", values)})"; } + /// + /// Extracts a subquery pipeline from an argument, which may be a bare + /// pipeExpr or a parenthesized `(pipeExpr)` expression. + /// + private static LqlParser.PipeExprContext? GetArgSubquery(LqlParser.ArgContext arg) => + arg.pipeExpr() ?? arg.expr()?.pipeExpr(); + + /// + /// Merges the enclosing lambda scope into a subquery visitor so that + /// correlation references like `row.users.id` resolve against the outer + /// query's table instead of leaking the raw lambda parameter. + /// + private static LqlToAstVisitor CreateSubqueryVisitor(HashSet? outerScope) + { + var visitor = new LqlToAstVisitor(); + if (outerScope != null) + { + visitor._lambdaScope = new HashSet( + outerScope, + StringComparer.OrdinalIgnoreCase + ); + } + return visitor; + } + + /// + /// Formats a subquery SQL body inside parentheses under the given keyword + /// (e.g. "EXISTS" or "IN"), indented by four spaces per line. + /// + private static string FormatSubquery(string keyword, string subquerySql) => + $"{keyword} (\n {subquerySql.Replace("\n", "\n ", StringComparison.Ordinal)}\n)"; + + /// + /// Converts a nested pipeline expression into a scalar SQL subquery. + /// Implements [LQL-PREDICATE-IN-SUBQUERY] and [LQL-PREDICATE-EXISTS-SUBQUERY]. + /// + private static string ProcessSubqueryToSql( + LqlParser.PipeExprContext pipeExpr, + HashSet? outerScope + ) + { + var node = CreateSubqueryVisitor(outerScope).VisitPipeExpr(pipeExpr); + return node is Pipeline pipeline + ? SubqueryLayout.CorrelatedBody(pipeline) + : throw new SqlErrorException( + CreateSqlErrorStatic("Unsupported subquery expression", pipeExpr) + ); + } + private static string ProcessInLeftExpressionToSql( LqlParser.InExprContext inExpr, HashSet? lambdaScope @@ -499,41 +605,14 @@ private static string ProcessInLeftExpressionToSql( private static string ProcessArithmeticExpressionToSql( LqlParser.ArithmeticExprContext arithmeticExpr, HashSet? lambdaScope - ) - { - // Process arithmetic terms - var terms = arithmeticExpr.arithmeticTerm(); - var results = new List(); - - for (int i = 0; i < terms.Length; i++) - { - if (i > 0) - { - // Extract actual operator from context - // The operator is between the terms, so we look at child nodes - var operatorIndex = (i * 2) - 1; // Operators are at odd indices: term op term op term - if (operatorIndex < arithmeticExpr.ChildCount) - { - var operatorNode = arithmeticExpr.GetChild(operatorIndex); - if (operatorNode is ITerminalNode terminalNode) - { - results.Add($" {terminalNode.GetText()} "); - } - else - { - results.Add(" + "); // Fallback to plus if we can't extract operator - } - } - else - { - results.Add(" + "); // Fallback to plus if index is out of bounds - } - } - results.Add(ProcessArithmeticTermToSql(terms[i], lambdaScope)); - } - - return string.Join("", results); - } + ) => + ProcessArithmeticSequence( + expression: arithmeticExpr, + operands: arithmeticExpr.arithmeticTerm(), + processOperand: term => + ProcessArithmeticTermToSql(arithmeticTerm: term, lambdaScope: lambdaScope), + fallbackOperator: "+" + ); /// /// Processes an arithmetic term to SQL text, respecting lambda variable scope. @@ -544,40 +623,40 @@ private static string ProcessArithmeticExpressionToSql( private static string ProcessArithmeticTermToSql( LqlParser.ArithmeticTermContext arithmeticTerm, HashSet? lambdaScope + ) => + ProcessArithmeticSequence( + expression: arithmeticTerm, + operands: arithmeticTerm.arithmeticFactor(), + processOperand: factor => + ProcessArithmeticFactorToSql(arithmeticFactor: factor, lambdaScope: lambdaScope), + fallbackOperator: "*" + ); + + private static string ProcessArithmeticSequence( + ParserRuleContext expression, + IReadOnlyList operands, + Func processOperand, + string fallbackOperator ) { - // Process arithmetic factors - var factors = arithmeticTerm.arithmeticFactor(); - var results = new List(); - - for (int i = 0; i < factors.Length; i++) + var result = new StringBuilder(); + for (var index = 0; index < operands.Count; index++) { - if (i > 0) + if (index > 0) { - // Extract actual operator from context - // The operator is between the factors, so we look at child nodes - var operatorIndex = (i * 2) - 1; // Operators are at odd indices: factor op factor op factor - if (operatorIndex < arithmeticTerm.ChildCount) - { - var operatorNode = arithmeticTerm.GetChild(operatorIndex); - if (operatorNode is ITerminalNode terminalNode) - { - results.Add($" {terminalNode.GetText()} "); - } - else - { - results.Add(" * "); // Fallback to multiply if we can't extract operator - } - } - else - { - results.Add(" * "); // Fallback to multiply if index is out of bounds - } + var operatorIndex = (index * 2) - 1; + var operatorText = + operatorIndex < expression.ChildCount + && expression.GetChild(operatorIndex) is ITerminalNode terminal + ? terminal.GetText() + : fallbackOperator; + result.Append(' ').Append(operatorText).Append(' '); } - results.Add(ProcessArithmeticFactorToSql(factors[i], lambdaScope)); + + result.Append(processOperand(operands[index])); } - return string.Join("", results); + return result.ToString(); } /// @@ -785,6 +864,17 @@ private static string ProcessFnCallExprToSql( { return $"{fnName}()"; } + + // Implements [LQL-PREDICATE-EXISTS-SUBQUERY]: exists(pipeExpr) + // becomes an EXISTS (SELECT ...) correlated subquery. + if ( + string.Equals(fnName, "exists", StringComparison.OrdinalIgnoreCase) + && args.arg().Length == 1 + && GetArgSubquery(args.arg()[0]) is { } existsSubquery + ) + { + return FormatSubquery("EXISTS", ProcessSubqueryToSql(existsSubquery, lambdaScope)); + } var argTexts = args.arg().Select(a => ProcessFnCallArgToSql(a, lambdaScope)).ToList(); return $"{fnName}({string.Join(", ", argTexts)})"; } @@ -913,7 +1003,7 @@ private IStep ConvertToStep(INode baseNode, LqlParser.ExprContext expr) "join" => CreateJoinStepWithType(baseNode, args, "INNER JOIN", expr), "left_join" => CreateJoinStepWithType(baseNode, args, "LEFT JOIN", expr), "cross_join" => CreateJoinStepWithType(baseNode, args, "CROSS JOIN", expr), - "filter" => CreateFilterStep(baseNode, args), + "filter" => CreateFilterStepInstance(baseNode, args), "select" => CreateSelectStep(baseNode, args), "select_distinct" => CreateSelectDistinctStep(baseNode, args), "group_by" => CreateGroupByStep(baseNode, args), @@ -950,7 +1040,13 @@ LqlParser.ExprContext context throw new SqlErrorException(CreateSqlError("join requires table", context)); } - string rightTable = ExtractIdentifier(args[0]); + // Implements [LQL-DERIVED-TABLE]: join((pipeline), on = ...) joins a derived table. + var derived = GetArgSubquery(args[0]) is { } subquery + ? AsPipeline(Visit(subquery), subquery) + : null; + string rightTable = derived is null + ? ExtractIdentifier(args[0]) + : BaseTableName(derived, context); string? onCondition = null; // For CROSS JOIN, no ON condition is needed @@ -966,23 +1062,38 @@ LqlParser.ExprContext context // For now, we'll use empty string as leftTable - this gets resolved later in pipeline processing var joinRelationship = new JoinRelationship("", rightTable, onCondition ?? "", joinType); - return new JoinStep { Base = baseNode, JoinRelationship = joinRelationship }; + return new JoinStep + { + Base = baseNode, + JoinRelationship = joinRelationship, + DerivedTable = derived, + }; } + /// + /// The base table a derived-table pipeline reads from; it names the derived table's alias. + /// + private static string BaseTableName(Pipeline pipeline, ParserRuleContext context) => + pipeline.Steps.OfType().FirstOrDefault()?.Base is Identifier table + ? table.Name + : throw new SqlErrorException( + CreateSqlErrorStatic("A derived table pipeline needs a base table", context) + ); + /// /// Creates a filter step. /// /// The base node. /// The arguments. /// The filter step. - private static FilterStep CreateFilterStep(INode baseNode, LqlParser.ArgContext[] args) + private FilterStep CreateFilterStepInstance(INode baseNode, LqlParser.ArgContext[] args) { WhereCondition condition; if (args.Length > 0) { // Try to extract condition from arguments and convert to typed WhereCondition - var conditionText = ExtractConditionFromLambda(args[0]); + var conditionText = ExtractConditionFromLambda(args[0], _lambdaScope); condition = WhereCondition.FromExpression(conditionText); } else @@ -1212,13 +1323,17 @@ private static InsertStep CreateInsertStep(INode baseNode, LqlParser.ArgContext[ /// Extracts condition from lambda or other argument types. /// /// The argument context. + /// Variables from any enclosing lambda. /// The condition string. - private static string ExtractConditionFromLambda(LqlParser.ArgContext arg) + private static string ExtractConditionFromLambda( + LqlParser.ArgContext arg, + HashSet? inheritedScope = null + ) { // Check if this is a lambda expression directly if (arg.lambdaExpr() != null) { - return ExtractLambdaCondition(arg.lambdaExpr()); + return ExtractLambdaCondition(arg.lambdaExpr(), inheritedScope); } // Check if this is a lambda function expression @@ -1227,7 +1342,7 @@ private static string ExtractConditionFromLambda(LqlParser.ArgContext arg) var exprContext = arg.expr(); if (exprContext.lambdaExpr() != null) { - return ExtractLambdaCondition(exprContext.lambdaExpr()); + return ExtractLambdaCondition(exprContext.lambdaExpr(), inheritedScope); } throw new SqlErrorException( @@ -1258,7 +1373,7 @@ private static string ExtractConditionFromLambda(LqlParser.ArgContext arg) // Check if this is a lambda expression directly if (arg.lambdaExpr() != null) { - return ExtractLambdaCondition(arg.lambdaExpr()); + return ExtractLambdaCondition(arg.lambdaExpr(), inheritedScope); } throw new SqlErrorException( @@ -1273,8 +1388,12 @@ private static string ExtractConditionFromLambda(LqlParser.ArgContext arg) /// Extracts condition from a lambda expression. /// /// The lambda expression context. + /// Variables from any enclosing lambda. /// The condition string. - private static string ExtractLambdaCondition(LqlParser.LambdaExprContext lambdaExpr) + private static string ExtractLambdaCondition( + LqlParser.LambdaExprContext lambdaExpr, + HashSet? inheritedScope = null + ) { // Extract the lambda variable names var parameters = lambdaExpr.IDENT().Select(ident => ident.GetText()).ToList(); @@ -1283,7 +1402,7 @@ private static string ExtractLambdaCondition(LqlParser.LambdaExprContext lambdaE if (logicalExpr != null) { // Use the proper ANTLR visitor to process the logical expression - return ProcessLambdaLogicalExpr(logicalExpr, parameters); + return ProcessLambdaLogicalExpr(logicalExpr, parameters, inheritedScope); } throw new SqlErrorException( @@ -1310,35 +1429,37 @@ private static string ExtractLambdaCondition(LqlParser.LambdaExprContext lambdaE // Check for IDENT first if (namedArg.IDENT()?.GetText() == name) { - var comparisonText = - namedArg.comparison() != null - ? ProcessComparisonToSql(namedArg.comparison(), null) - : null; - var logicalText = - namedArg.logicalExpr() != null - ? ProcessLogicalExpressionToSql(namedArg.logicalExpr(), null) - : null; - return comparisonText ?? logicalText; + return ResolveNamedArgText(namedArg: namedArg); } // Check for ON keyword if (name == "on" && namedArg.ON() != null) { - var comparisonText = - namedArg.comparison() != null - ? ProcessComparisonToSql(namedArg.comparison(), null) - : null; - var logicalText = - namedArg.logicalExpr() != null - ? ProcessLogicalExpressionToSql(namedArg.logicalExpr(), null) - : null; - return comparisonText ?? logicalText; + return ResolveNamedArgText(namedArg: namedArg); } } } return null; } + /// + /// Resolves the SQL text of a named argument from its comparison or logical expression. + /// + /// The named argument context. + /// The rendered SQL text, or null when neither form is present. + private static string? ResolveNamedArgText(LqlParser.NamedArgContext namedArg) + { + var comparisonText = + namedArg.comparison() != null + ? ProcessComparisonToSql(namedArg.comparison(), null) + : null; + var logicalText = + namedArg.logicalExpr() != null + ? ProcessLogicalExpressionToSql(namedArg.logicalExpr(), null) + : null; + return comparisonText ?? logicalText; + } + /// /// Extracts an identifier from an argument. /// @@ -1474,7 +1595,12 @@ private static string ExtractColumnAlias(LqlParser.ColumnAliasContext columnAlia /// /// The function call context. /// The formatted function call text. - private static string ExtractFunctionCall(LqlParser.FunctionCallContext functionCall) + private static string ExtractFunctionCall(LqlParser.FunctionCallContext functionCall) => + functionCall.windowSpec() is { } windowSpec + ? $"{ExtractPlainFunctionCall(functionCall)} {RenderWindowClause(windowSpec)}" + : ExtractPlainFunctionCall(functionCall); + + private static string ExtractPlainFunctionCall(LqlParser.FunctionCallContext functionCall) { // Build the function call properly using the grammar structure string functionName = functionCall.IDENT().GetText(); @@ -1552,38 +1678,34 @@ private static string ExtractWindowFunction(LqlParser.ExprContext context) functionCall = $"{functionName}({string.Join(", ", args)})"; } - // Build window specification - var windowSpec = context.windowSpec(); - string windowClause = "OVER ("; - - if (windowSpec.partitionClause() != null) - { - var partitionArgs = windowSpec - .partitionClause() - .argList() - .arg() - .Select(arg => StripTablePrefix(ExtractIdentifier(arg))) - .ToArray(); - windowClause += $"PARTITION BY {string.Join(", ", partitionArgs)}"; - } - - if (windowSpec.orderClause() != null) - { - if (windowSpec.partitionClause() != null) - windowClause += " "; - - var orderArgs = windowSpec - .orderClause() - .argList() - .arg() - .Select(ProcessWindowOrderItem) - .ToArray(); - windowClause += $"ORDER BY {string.Join(", ", orderArgs)}"; - } - - windowClause += ")"; + return $"{functionCall} {RenderWindowClause(context.windowSpec())}"; + } - return $"{functionCall} {windowClause}"; + /// + /// Renders a window specification as OVER (PARTITION BY ... ORDER BY ...). + /// + /// The window specification context. + /// The OVER clause. + private static string RenderWindowClause(LqlParser.WindowSpecContext windowSpec) + { + var partition = windowSpec.partitionClause() is { } partitionClause + ? "PARTITION BY " + + string.Join( + ", ", + partitionClause + .argList() + .arg() + .Select(arg => StripTablePrefix(ExtractIdentifier(arg))) + ) + : null; + var order = windowSpec.orderClause() is { } orderClause + ? "ORDER BY " + + string.Join( + ", ", + orderClause.orderByArgList().orderByArg().Select(ProcessWindowOrderItem) + ) + : null; + return $"OVER ({string.Join(" ", new[] { partition, order }.OfType())})"; } /// @@ -1614,45 +1736,23 @@ string columnRef /// /// Processes an order item for window functions, handling direction (ASC/DESC) properly. /// - /// The argument context containing the order item. + /// The ORDER BY item inside the window specification. /// The formatted order item with proper spacing. - private static string ProcessWindowOrderItem(LqlParser.ArgContext arg) + private static string ProcessWindowOrderItem(LqlParser.OrderByArgContext item) { - // Check if it's a comparison with orderDirection - if (arg.comparison() != null) - { - var comparison = arg.comparison(); - - // Check if it has an orderDirection - if (comparison.orderDirection() != null) - { - var direction = comparison.orderDirection().ASC() != null ? "ASC" : "DESC"; - - // Extract the column name (could be qualifiedIdent or IDENT) - var columnName = - comparison.qualifiedIdent(0) != null - ? ProcessQualifiedIdentifierToSql(comparison.qualifiedIdent(0), null) - : comparison.IDENT(0)?.GetText() - ?? throw new NotSupportedException( - "Unknown comparison type in ExtractWindowFunction" - ); - - return $"{StripTablePrefix(columnName)} {direction}"; - } - - // If no direction specified, just get the column name - var colName = - comparison.qualifiedIdent(0) != null - ? ProcessQualifiedIdentifierToSql(comparison.qualifiedIdent(0), null) - : comparison.IDENT(0)?.GetText() - ?? throw new NotSupportedException( - "Unsupported comparison type in ExtractWindowFunction" - ); - return StripTablePrefix(colName); - } - - // Fallback to simple extraction - return StripTablePrefix(ExtractIdentifier(arg)); + var column = + item.arithmeticExpr() != null ? ExtractArithmeticExpression(item.arithmeticExpr()) + : item.functionCall() != null ? ExtractFunctionCall(item.functionCall()) + : item.qualifiedIdent() != null + ? ProcessQualifiedIdentifierToSql(item.qualifiedIdent(), null) + : item.IDENT().GetText(); + var direction = item.orderDirection() switch + { + null => "", + var d when d.ASC() != null => " ASC", + _ => " DESC", + }; + return $"{StripTablePrefix(column)}{direction}"; } /// diff --git a/Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs b/Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs index c4da92a1..05f78cf2 100644 --- a/Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs +++ b/Lql/Nimblesite.Lql.Core/Parsing/LqlVisitor.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from Nimblesite.Lql.Core.g4 by ANTLR 4.13.1 +// Generated from Lql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -45,6 +45,18 @@ public interface ILqlVisitor : IParseTreeVisitor { /// The visitor result. Result VisitStatement([NotNull] LqlParser.StatementContext context); /// + /// Visit a parse tree produced by . + /// + /// The parse tree. + /// The visitor result. + Result VisitWithStmt([NotNull] LqlParser.WithStmtContext context); + /// + /// Visit a parse tree produced by . + /// + /// The parse tree. + /// The visitor result. + Result VisitCteDef([NotNull] LqlParser.CteDefContext context); + /// /// Visit a parse tree produced by . /// /// The parse tree. @@ -81,6 +93,18 @@ public interface ILqlVisitor : IParseTreeVisitor { /// The visitor result. Result VisitOrderClause([NotNull] LqlParser.OrderClauseContext context); /// + /// Visit a parse tree produced by . + /// + /// The parse tree. + /// The visitor result. + Result VisitOrderByArgList([NotNull] LqlParser.OrderByArgListContext context); + /// + /// Visit a parse tree produced by . + /// + /// The parse tree. + /// The visitor result. + Result VisitOrderByArg([NotNull] LqlParser.OrderByArgContext context); + /// /// Visit a parse tree produced by . /// /// The parse tree. diff --git a/Lql/Nimblesite.Lql.Core/PipelineShape.cs b/Lql/Nimblesite.Lql.Core/PipelineShape.cs new file mode 100644 index 00000000..f16f112d --- /dev/null +++ b/Lql/Nimblesite.Lql.Core/PipelineShape.cs @@ -0,0 +1,47 @@ +using System.Collections.Immutable; +using Nimblesite.Sql.Model; + +namespace Nimblesite.Lql.Core; + +/// +/// The clauses of one pipeline, collected from its steps for layout rendering. +/// Implements [LQL-SUBQUERY-LAYOUT]. +/// +internal sealed record PipelineShape( + string BaseTable, + ImmutableArray Joins, + // One condition per filter step; successive filters are ANDed when rendered. + ImmutableArray Where, + ImmutableArray Columns, + bool Distinct, + ImmutableArray GroupBy, + string? Having, + ImmutableArray<(string Column, string Direction)> OrderBy, + string? Limit, + string? Offset +) +{ + internal static PipelineShape From(Pipeline pipeline) => + pipeline.Steps.Aggregate( + new PipelineShape("", [], [], [], false, [], null, [], null, null), + (shape, step) => shape.With(step) + ); + + private PipelineShape With(IStep step) => + step switch + { + IdentityStep { Base: Identifier table } => this with { BaseTable = table.Name }, + JoinStep join => this with { Joins = Joins.Add(join) }, + FilterStep filter => this with { Where = Where.Add(filter.Condition) }, + SelectStep select => this with { Columns = select.Columns, Distinct = false }, + SelectDistinctStep select => this with { Columns = select.Columns, Distinct = true }, + GroupByStep groupBy => this with { GroupBy = groupBy.Columns }, + HavingStep having => this with { Having = having.Condition }, + OrderByStep orderBy => this with { OrderBy = orderBy.OrderItems }, + LimitStep limit => this with { Limit = limit.Count }, + OffsetStep offset => this with { Offset = offset.Count }, + _ => throw new NotSupportedException( + $"{step.GetType().Name} is not supported in a subquery or CTE pipeline" + ), + }; +} diff --git a/Lql/Nimblesite.Lql.Core/QualifierRewriter.cs b/Lql/Nimblesite.Lql.Core/QualifierRewriter.cs new file mode 100644 index 00000000..d7e330a7 --- /dev/null +++ b/Lql/Nimblesite.Lql.Core/QualifierRewriter.cs @@ -0,0 +1,87 @@ +using System.Text; + +namespace Nimblesite.Lql.Core; + +/// +/// Rewrites table.column qualifiers in rendered SQL expressions: a qualifier mapped +/// to an alias is replaced by it, one mapped to an empty string is removed. String literals +/// and quoted identifiers are copied verbatim. Implements [LQL-SUBQUERY-LAYOUT]. +/// +internal static class QualifierRewriter +{ + internal static string Rewrite( + string expression, + IReadOnlyDictionary qualifiers + ) + { + var sql = new StringBuilder(expression.Length); + var i = 0; + while (i < expression.Length) + { + i = expression[i] switch + { + '\'' or '"' => CopyQuoted(expression, i, sql), + var c when IsQualifierStart(expression, i, c) => CopyIdentifier( + expression, + i, + qualifiers, + sql + ), + var c => Copy(c, i, sql), + }; + } + return sql.ToString(); + } + + // Only the first segment of a dotted path can be a table qualifier. + private static bool IsQualifierStart(string expression, int i, char c) => + (char.IsLetter(c) || c == '_') + && ( + i == 0 || !(char.IsLetterOrDigit(expression[i - 1]) || expression[i - 1] is '_' or '.') + ); + + private static int CopyIdentifier( + string expression, + int start, + IReadOnlyDictionary qualifiers, + StringBuilder sql + ) + { + var end = start; + while ( + end < expression.Length + && (char.IsLetterOrDigit(expression[end]) || expression[end] == '_') + ) + { + end++; + } + var identifier = expression[start..end]; + var qualifies = end < expression.Length && expression[end] == '.'; + if (qualifies && qualifiers.TryGetValue(identifier, out var replacement)) + { + sql.Append(replacement); + return replacement.Length == 0 ? end + 1 : end; + } + sql.Append(identifier); + return end; + } + + private static int CopyQuoted(string expression, int start, StringBuilder sql) + { + var quote = expression[start]; + var end = start + 1; + while (end < expression.Length && expression[end] != quote) + { + end++; + } + end = Math.Min(end + 1, expression.Length); + sql.Append(expression, start, end - start); + return end; + } + + private static int Copy(char c, int i, StringBuilder sql) + { + sql.Append(c); + return i + 1; + } +} diff --git a/Lql/Nimblesite.Lql.Core/SqlPaging.cs b/Lql/Nimblesite.Lql.Core/SqlPaging.cs new file mode 100644 index 00000000..e5a2d9ba --- /dev/null +++ b/Lql/Nimblesite.Lql.Core/SqlPaging.cs @@ -0,0 +1,27 @@ +using System.Collections.Immutable; + +namespace Nimblesite.Lql.Core; + +/// +/// A dialect's rendering of LIMIT/OFFSET: a prefix placed after SELECT (such as +/// SQL Server's TOP n) and trailing clause lines. Implements [LQL-SUBQUERY-LAYOUT]. +/// +/// Text inserted after SELECT, or empty. +/// Clause lines appended after ORDER BY. +public sealed record SqlPaging(string SelectPrefix, ImmutableArray TailLines) +{ + /// + /// LIMIT/OFFSET lines used by SQLite and PostgreSQL. + /// + /// The row limit, if any. + /// The row offset, if any. + /// The paging rendering. + public static SqlPaging LimitOffset(string? limit, string? offset) => + new( + "", + [ + .. limit is null ? Array.Empty() : [$"LIMIT {limit}"], + .. offset is null ? Array.Empty() : [$"OFFSET {offset}"], + ] + ); +} diff --git a/Lql/Nimblesite.Lql.Core/SubqueryLayout.cs b/Lql/Nimblesite.Lql.Core/SubqueryLayout.cs new file mode 100644 index 00000000..a746895c --- /dev/null +++ b/Lql/Nimblesite.Lql.Core/SubqueryLayout.cs @@ -0,0 +1,262 @@ +using System.Collections.Immutable; +using Nimblesite.Sql.Model; + +namespace Nimblesite.Lql.Core; + +/// +/// Renders nested pipelines — common table expressions, derived-table joins, and +/// EXISTS/IN subquery bodies — in one clause-per-line layout shared by every dialect. +/// Only paging syntax differs per dialect. Implements [LQL-CTE], [LQL-DERIVED-TABLE] +/// and [LQL-SUBQUERY-LAYOUT]. +/// +public static class SubqueryLayout +{ + private const string Indent = " "; + + /// + /// True when the statement needs this layout: it declares CTEs or joins a derived table. + /// + /// The statement's AST node. + /// Whether should render the statement. + public static bool Applies(INode? node) => + node is WithQuery + || node is Pipeline pipeline + && pipeline.Steps.OfType().Any(join => join.DerivedTable is not null); + + /// + /// Renders a statement that to. + /// + /// The statement's AST node. + /// The dialect's LIMIT/OFFSET rendering. + /// The SQL text. + public static string Render(INode node, Func paging) => + node switch + { + WithQuery query => RenderWith(query, paging), + Pipeline pipeline => Query(pipeline, paging), + _ => throw new NotSupportedException($"Cannot lay out {node.GetType().Name}"), + }; + + /// + /// Renders an EXISTS/IN subquery body. It may correlate with the outer query, so table + /// qualifiers are kept, and it selects 1 when no columns are chosen. + /// + internal static string CorrelatedBody(Pipeline pipeline) => + string.Join( + "\n", + Lines(PipelineShape.From(pipeline), [], NoPaging, defaultColumns: "1", correlated: true) + ); + + private static string RenderWith(WithQuery query, Func paging) => + "WITH " + + string.Join( + ",\n", + query.Ctes.Select(cte => $"{cte.Name} AS {Block(Query(cte.Query, paging))}") + ) + + $"\n{Query(query.Body, paging)}"; + + private static string Query(Pipeline pipeline, Func paging) + { + var shape = PipelineShape.From(pipeline); + return string.Join( + "\n", + Lines(shape, Aliases(shape), paging, defaultColumns: "*", correlated: false) + ); + } + + private static IEnumerable Lines( + PipelineShape shape, + ImmutableArray aliases, + Func paging, + string defaultColumns, + bool correlated + ) + { + var qualifiers = Qualifiers(shape, aliases, correlated); + var page = paging(shape.Limit, shape.Offset); + return + [ + SelectLine(shape, qualifiers, page.SelectPrefix, defaultColumns), + Source("FROM", shape.BaseTable, aliases.FirstOrDefault()), + .. shape.Joins.Select( + (join, i) => JoinLine(join, aliases.ElementAtOrDefault(i + 1), qualifiers, paging) + ), + .. Clause("WHERE", shape.Where.IsEmpty ? null : Where(shape.Where, qualifiers)), + .. Clause( + "GROUP BY", + shape.GroupBy.IsEmpty + ? null + : string.Join( + ", ", + shape.GroupBy.Select(c => QualifierRewriter.Rewrite(c, qualifiers)) + ) + ), + .. Clause( + "HAVING", + shape.Having is null ? null : QualifierRewriter.Rewrite(shape.Having, qualifiers) + ), + .. Clause( + "ORDER BY", + shape.OrderBy.IsEmpty + ? null + : string.Join( + ", ", + shape.OrderBy.Select(o => + $"{QualifierRewriter.Rewrite(o.Column, qualifiers)} {o.Direction}" + ) + ) + ), + .. page.TailLines, + ]; + } + + private static string SelectLine( + PipelineShape shape, + IReadOnlyDictionary qualifiers, + string prefix, + string defaultColumns + ) => + $"SELECT {prefix}{(shape.Distinct ? "DISTINCT " : "")}" + + ( + shape.Columns.IsEmpty + ? defaultColumns + : string.Join(", ", shape.Columns.Select(c => Column(c, qualifiers))) + ); + + private static string Source(string keyword, string table, string? alias) => + table.Length == 0 + ? throw new NotSupportedException("A subquery or CTE pipeline needs a base table") + : string.IsNullOrEmpty(alias) ? $"{keyword} {table}" + : $"{keyword} {table} {alias}"; + + private static string JoinLine( + JoinStep join, + string? alias, + IReadOnlyDictionary qualifiers, + Func paging + ) + { + var relationship = join.JoinRelationship; + var source = join.DerivedTable is { } derived + ? Block(Query(derived, paging)) + : relationship.RightTable; + var on = string.IsNullOrEmpty(relationship.Condition) + ? "" + : $" ON {QualifierRewriter.Rewrite(relationship.Condition, qualifiers)}"; + return $"{Source(relationship.JoinType, source, alias)}{on}"; + } + + // Successive filters narrow the rows; each is parenthesized when there are several so + // an OR inside one filter cannot bind across the AND. + private static string Where( + ImmutableArray filters, + IReadOnlyDictionary qualifiers + ) => + filters.Length == 1 + ? Condition(filters[0], qualifiers) + : string.Join(" AND ", filters.Select(f => $"({Condition(f, qualifiers)})")); + + private static string Condition( + WhereCondition condition, + IReadOnlyDictionary qualifiers + ) => + condition switch + { + ExpressionCondition expression => QualifierRewriter.Rewrite( + expression.Expression, + qualifiers + ), + ComparisonCondition comparison => + $"{Column(comparison.Left, qualifiers)} {comparison.Operator.ToSql()} {QualifierRewriter.Rewrite(comparison.Right, qualifiers)}", + _ => throw new NotSupportedException( + $"Unsupported filter condition: {condition.GetType().Name}" + ), + }; + + private static string Column( + ColumnInfo column, + IReadOnlyDictionary qualifiers + ) => + column switch + { + NamedColumn named => Qualified(named.TableAlias, named.Name, qualifiers), + ExpressionColumn expression => QualifierRewriter.Rewrite( + expression.Expression, + qualifiers + ), + WildcardColumn wildcard => Qualified(wildcard.TableAlias, "*", qualifiers), + _ => throw new NotSupportedException($"Unsupported column: {column.GetType().Name}"), + } + (column.Alias is null ? "" : $" AS {column.Alias}"); + + private static string Qualified( + string? table, + string name, + IReadOnlyDictionary qualifiers + ) => + table is null ? name + : qualifiers.TryGetValue(table, out var alias) + ? (alias.Length == 0 ? name : $"{alias}.{name}") + : $"{table}.{name}"; + + // Joined queries alias every source by its initials (users -> u, high_value_customers + // -> hvc); a single-table query needs no aliases. + private static ImmutableArray Aliases(PipelineShape shape) => + shape.Joins.IsEmpty + ? [] + : + [ + .. new[] { shape.BaseTable } + .Concat(shape.Joins.Select(join => join.JoinRelationship.RightTable)) + .Aggregate( + ImmutableList.Empty, + (taken, name) => taken.Add(UniqueAlias(Initials(name), taken)) + ), + ]; + + // Qualifiers become the source aliases. Without aliases a standalone single-table body + // drops its qualifier; a correlated body keeps it to tell inner from outer columns. + private static ImmutableDictionary Qualifiers( + PipelineShape shape, + ImmutableArray aliases, + bool correlated + ) => + correlated ? ImmutableDictionary.Empty + : aliases.IsEmpty + ? ImmutableDictionary + .Empty.WithComparers(StringComparer.OrdinalIgnoreCase) + .Add(shape.BaseTable, "") + : new[] { shape.BaseTable } + .Concat(shape.Joins.Select(join => join.JoinRelationship.RightTable)) + .Zip(aliases) + .GroupBy(pair => pair.First, StringComparer.OrdinalIgnoreCase) + .ToImmutableDictionary( + g => g.Key, + g => g.First().Second, + StringComparer.OrdinalIgnoreCase + ); + + private static string Initials(string name) => + string.Concat( + name.Split('_', StringSplitOptions.RemoveEmptyEntries) + .Select(part => char.ToLowerInvariant(part[0])) + ); + + private static string UniqueAlias(string alias, ImmutableList taken) => + Enumerable + .Range(1, taken.Count + 1) + .Select(n => n == 1 ? alias : $"{alias}{n}") + .First(candidate => !taken.Contains(candidate)); + + private static IEnumerable Clause(string keyword, string? body) => + body is null ? [] : [$"{keyword} {body}"]; + + private static string Block(string sql) => + $"(\n{string.Join("\n", sql.Split('\n').Select(line => Indent + line))}\n)"; + + private static SqlPaging NoPaging(string? limit, string? offset) => + limit is null && offset is null + ? new SqlPaging("", []) + : throw new NotSupportedException( + "limit/offset are not supported inside EXISTS or IN subqueries" + ); +} diff --git a/Lql/Nimblesite.Lql.Core/WithQuery.cs b/Lql/Nimblesite.Lql.Core/WithQuery.cs new file mode 100644 index 00000000..26e83a43 --- /dev/null +++ b/Lql/Nimblesite.Lql.Core/WithQuery.cs @@ -0,0 +1,18 @@ +using System.Collections.Immutable; + +namespace Nimblesite.Lql.Core; + +/// +/// A statement with common table expressions: with name as (pipeline) main-pipeline. +/// Implements [LQL-CTE]. +/// +/// The named pipelines, in declaration order. +/// The main pipeline, which may reference the CTE names as tables. +public sealed record WithQuery(ImmutableArray Ctes, Pipeline Body) : INode; + +/// +/// One named pipeline of a . +/// +/// The CTE name. +/// The pipeline the name stands for. +public sealed record CommonTableExpression(string Name, Pipeline Query); diff --git a/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs b/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs index 2670eb53..55e4bc61 100644 --- a/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs +++ b/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs @@ -170,6 +170,17 @@ public string GenerateSQL() { var statement = _builder.Build(); + return GenerateStatementSQL(statement); + } + + /// Generates PostgreSQL SQL from an already-built statement. + /// The SQL statement to generate from + /// The PostgreSQL SQL string + public static string ToPostgreSqlSql(SelectStatement statement) => + GenerateStatementSQL(statement); + + private static string GenerateStatementSQL(SelectStatement statement) + { if (statement.Unions.Count > 0) { return GenerateUnionSQL(statement); @@ -183,7 +194,7 @@ public string GenerateSQL() /// /// The SQL statement to generate from /// The SELECT SQL string - private string GenerateSelectSQL(SelectStatement statement) + private static string GenerateSelectSQL(SelectStatement statement) { var sql = new StringBuilder(); @@ -311,9 +322,9 @@ private static string RewriteFullTableRefsToAliases(string sql, SelectStatement } // Quoted identifier `"foo"`. If followed by `.` it's a - // `"fhir_Slot".` qualifier and can be rewritten to its alias. + // `"MixedTable".` qualifier and can be rewritten to its alias. // Otherwise (followed by whitespace/end) it's the FROM/JOIN - // declaration form `"fhir_Slot" f` and must be left alone. + // declaration form `"MixedTable" f` and must be left alone. if (c == '"') { var quoteStart = i; @@ -377,7 +388,7 @@ private static string RewriteFullTableRefsToAliases(string sql, SelectStatement /// /// The SQL statement to generate from /// The UNION SQL string - private string GenerateUnionSQL(SelectStatement statement) + private static string GenerateUnionSQL(SelectStatement statement) { var sql = new StringBuilder(); @@ -434,13 +445,8 @@ private static string GenerateSelectClause(SelectStatement statement) /// /// The SQL statement to generate from /// The FROM clause string - private string GenerateFromClause(SelectStatement statement) + private static string GenerateFromClause(SelectStatement statement) { - if (_baseTable == null) - { - return ""; - } - var sql = new StringBuilder(); var baseTable = statement.Tables.Count > 0 ? statement.Tables.First() : null; @@ -497,7 +503,7 @@ private string GenerateFromClause(SelectStatement statement) /// The SQL statement to generate from /// The WHERE clause string private static string GenerateWhereClause(SelectStatement statement) => - $"\nWHERE {string.Join(" AND ", statement.WhereConditions.Select(GenerateWhereConditionSql))}"; + $"\nWHERE {WhereConditionSql.Join(statement.WhereConditions, GenerateWhereConditionSql)}"; /// /// Generates SQL for a single WHERE condition @@ -519,7 +525,7 @@ private static string GenerateWhereConditionSql(WhereCondition condition) => /// so PostgreSQL preserves their case (matches the FormatTableName /// behaviour above). ExpressionColumn values pass through /// QuoteIdentifier as well so qualified references like - /// `icd10_chapter.Id` get the trailing component quoted. + /// `reference_table.Id` get the trailing component quoted. /// /// The column info /// The SQL string for the column @@ -530,7 +536,7 @@ private static string GenerateColumnSql(ColumnInfo columnInfo) => ? QuoteIdentifier(n.Name) // Bug #24: also quote the TableAlias if it needs it. // When the FROM clause uses a quoted mixed-case table name - // (e.g. `FROM "fhir_Patient"`), the column qualifier must + // (e.g. `FROM "MixedTable"`), the column qualifier must // also be quoted to look up the same table. : $"{QuoteBareIdentifier(n.TableAlias)}.{QuoteIdentifier(n.Name)}", WildcardColumn w => string.IsNullOrEmpty(w.TableAlias) @@ -604,7 +610,7 @@ private string GenerateTableAlias(string tableName) /// /// Formats a table name for PostgreSQL. PostgreSQL folds unquoted /// identifiers to lower case, so any identifier that contains an - /// uppercase character (e.g. `fhir_Patient`) MUST be double-quoted + /// uppercase character (e.g. `MixedTable`) MUST be double-quoted /// to survive a round-trip. Identifiers that are already lowercase /// (the previous behaviour) are emitted unquoted to preserve the /// existing test fixture output. @@ -807,11 +813,11 @@ private static string QuoteInlineQualifiedIdents(string expression) var tailIdent = expression[tailStart..tailEnd]; // Bug #24: also quote the prefix when it needs - // quoting (e.g. `fhir_Patient.Id` -> - // `"fhir_Patient"."Id"`). PG folds the unquoted + // quoting (e.g. `MixedTable.Id` -> + // `"MixedTable"."Id"`). PG folds the unquoted // table-name qualifier to lower case otherwise // and the lookup against a quoted FROM table - // ("fhir_Patient") fails. + // ("MixedTable") fails. if (NeedsQuoting(firstIdent)) { sb.Append('"').Append(firstIdent).Append('"'); diff --git a/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMapping.cs b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMapping.cs index a3178c1c..9fcbf49a 100644 --- a/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMapping.cs +++ b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMapping.cs @@ -23,7 +23,7 @@ private PostgreSqlFunctionMapping() /// Creates the PostgreSQL function mappings /// /// Dictionary of function mappings - private static ImmutableDictionary CreateFunctionMappings() => + internal static ImmutableDictionary CreateFunctionMappings() => new Dictionary { ["count"] = new( @@ -69,7 +69,7 @@ private static ImmutableDictionary CreateFunctionMappings() /// Creates the PostgreSQL syntax mapping ///
/// The PostgreSQL syntax mapping - private static SqlSyntaxMapping CreateSyntaxMapping() => + internal static SqlSyntaxMapping CreateSyntaxMapping() => new( LimitClause: "LIMIT {0}", OffsetClause: "OFFSET {0}", diff --git a/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMappingLocal.cs b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMappingLocal.cs index 1c770fb7..72edb500 100644 --- a/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMappingLocal.cs +++ b/Lql/Nimblesite.Lql.Postgres/PostgreSqlFunctionMappingLocal.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using Nimblesite.Lql.Core.FunctionMapping; namespace Nimblesite.Lql.Postgres; @@ -17,67 +16,8 @@ public sealed class PostgreSqlFunctionMappingLocal : FunctionMappingProviderBase /// Private constructor to enforce singleton pattern ///
private PostgreSqlFunctionMappingLocal() - : base(CreateFunctionMappings, CreateSyntaxMapping) { } - - /// - /// Creates the PostgreSQL function mappings - /// - /// Dictionary of function mappings - private static ImmutableDictionary CreateFunctionMappings() => - new Dictionary - { - ["count"] = new( - "count", - "COUNT", - RequiresSpecialHandling: true, - SpecialHandler: args => - args.Length == 1 && args[0] == "*" - ? "COUNT(*)" - : $"COUNT({string.Join(", ", args)})" - ), - ["sum"] = new("sum", "SUM"), - ["avg"] = new("avg", "AVG"), - ["min"] = new("min", "MIN"), - ["max"] = new("max", "MAX"), - ["coalesce"] = new("coalesce", "COALESCE"), - ["extract"] = new("extract", "EXTRACT"), - ["date_trunc"] = new("date_trunc", "DATE_TRUNC"), - ["current_date"] = new( - "current_date", - "CURRENT_DATE", - RequiresSpecialHandling: true, - SpecialHandler: _ => "CURRENT_DATE" - ), - ["length"] = new("length", "LENGTH"), - ["upper"] = new("upper", "UPPER"), - ["lower"] = new("lower", "LOWER"), - ["substring"] = new("substring", "SUBSTRING"), - ["exists"] = new( - "exists", - "EXISTS", - RequiresSpecialHandling: true, - SpecialHandler: args => $"EXISTS ({string.Join(" ", args)})" - ), - ["row_number"] = new("row_number", "ROW_NUMBER"), - ["rank"] = new("rank", "RANK"), - ["dense_rank"] = new("dense_rank", "DENSE_RANK"), - ["lag"] = new("lag", "LAG"), - ["lead"] = new("lead", "LEAD"), - }.ToImmutableDictionary(); - - /// - /// Creates the PostgreSQL syntax mapping - /// - /// The PostgreSQL syntax mapping - private static SqlSyntaxMapping CreateSyntaxMapping() => - new( - LimitClause: "LIMIT {0}", - OffsetClause: "OFFSET {0}", - DateCurrentFunction: "CURRENT_DATE", - DateAddFunction: "INTERVAL '{0} {1}'", - StringLengthFunction: "LENGTH", - StringConcatOperator: "||", - IdentifierQuoteChar: "\"", - SupportsBoolean: true - ); + : base( + PostgreSqlFunctionMapping.CreateFunctionMappings, + PostgreSqlFunctionMapping.CreateSyntaxMapping + ) { } } diff --git a/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs b/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs index f146a178..177f7f62 100644 --- a/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs +++ b/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs @@ -17,20 +17,21 @@ public static Result ToPostgreSql(this LqlStatement statement) { ArgumentNullException.ThrowIfNull(statement); - if (statement.ParseError != null) + if (LqlStatementValidator.Validate(statement) is SqlError error) { - return new Result.Error(statement.ParseError); - } - - if (statement.AstNode == null) - { - return new Result.Error( - new SqlError("No AST node found in statement") - ); + return new Result.Error(error); } try { + // Implements [LQL-CTE] and [LQL-DERIVED-TABLE]. + if (statement.AstNode is { } node && SubqueryLayout.Applies(node)) + { + return new Result.Ok( + SubqueryLayout.Render(node, SqlPaging.LimitOffset) + ); + } + if (statement.AstNode is Pipeline pipeline) { var sql = ConvertPipelineToPostgreSQL(pipeline); @@ -59,6 +60,25 @@ private static string ConvertPipelineToPostgreSQL(Pipeline pipeline) return PipelineProcessor.ConvertPipelineToSql(pipeline, context, ProcessColumnReferences); } + /// + /// Converts a Nimblesite.Sql.Model.SelectStatement to PostgreSQL syntax. + /// Implements [DP-SQL-MODEL-DIALECTS]. + /// + /// The SelectStatement to convert + /// A Result containing either PostgreSQL SQL string or a SqlError + public static Result ToPostgreSql(this SelectStatement statement) + { + try + { + var sql = PostgreSqlContext.ToPostgreSqlSql(statement); + return new Result.Ok(sql); + } + catch (Exception ex) + { + return new Result.Error(SqlError.FromException(ex)); + } + } + /// /// Wraps a bare identifier in double quotes only when it contains /// uppercase ASCII (which Postgres would otherwise fold). Lower-case diff --git a/Lql/Nimblesite.Lql.SQLite/SQLiteContext.cs b/Lql/Nimblesite.Lql.SQLite/SQLiteContext.cs index a361277c..d92d1449 100644 --- a/Lql/Nimblesite.Lql.SQLite/SQLiteContext.cs +++ b/Lql/Nimblesite.Lql.SQLite/SQLiteContext.cs @@ -208,6 +208,7 @@ private void ProcessLimitStep(LimitStep limitStep) => private static string GenerateSQLiteSQL(SelectStatement statement) { var sql = new System.Text.StringBuilder(); + var separator = ClauseSeparator(statement); // SELECT clause sql.Append("SELECT "); @@ -228,7 +229,7 @@ private static string GenerateSQLiteSQL(SelectStatement statement) // FROM clause with JOINs if (statement.Tables.Count > 0) { - sql.Append(" FROM "); + sql.Append(separator).Append("FROM "); var baseTable = statement.Tables.First(); sql.Append(baseTable.Name); if (!string.IsNullOrEmpty(baseTable.Alias)) @@ -255,7 +256,7 @@ private static string GenerateSQLiteSQL(SelectStatement statement) sql.Append( System.Globalization.CultureInfo.InvariantCulture, - $" {fullJoinType} {table.Name}" + $"{separator}{fullJoinType} {table.Name}" ); if (!string.IsNullOrEmpty(table.Alias)) @@ -279,63 +280,28 @@ private static string GenerateSQLiteSQL(SelectStatement statement) // WHERE clause if (statement.WhereConditions.Count > 0) { - sql.Append(" WHERE "); - for (int i = 0; i < statement.WhereConditions.Count; i++) - { - var condition = statement.WhereConditions[i]; - var formatted = FormatWhereCondition(condition); - - // Add space before condition based on specific rules - if (i > 0) - { - var prevCondition = statement.WhereConditions[i - 1]; - - // Space before closing parenthesis only if prev wasn't opening - if (condition is Parenthesis { IsOpening: false }) - { - if (prevCondition is not Parenthesis { IsOpening: true }) - { - sql.Append(' '); - } - } - // Space before everything else except after opening parenthesis - else if (prevCondition is not Parenthesis { IsOpening: true }) - { - sql.Append(' '); - } - } - - sql.Append(formatted); - - // Add space after opening parenthesis only - if ( - condition is Parenthesis { IsOpening: true } - && i < statement.WhereConditions.Count - 1 - ) - { - sql.Append(' '); - } - } + sql.Append(separator).Append("WHERE "); + sql.Append(WhereConditionSql.Join(statement.WhereConditions, FormatWhereCondition)); } // GROUP BY clause if (statement.GroupByColumns.Count > 0) { - sql.Append(" GROUP BY "); + sql.Append(separator).Append("GROUP BY "); sql.Append(string.Join(", ", statement.GroupByColumns.Select(FormatColumn))); } // HAVING clause if (!string.IsNullOrEmpty(statement.HavingCondition)) { - sql.Append(" HAVING "); + sql.Append(separator).Append("HAVING "); sql.Append(statement.HavingCondition); } // ORDER BY clause if (statement.OrderByItems.Count > 0) { - sql.Append(" ORDER BY "); + sql.Append(separator).Append("ORDER BY "); sql.Append( string.Join(", ", statement.OrderByItems.Select(o => $"{o.Column} {o.Direction}")) ); @@ -344,20 +310,32 @@ private static string GenerateSQLiteSQL(SelectStatement statement) // LIMIT clause if (!string.IsNullOrEmpty(statement.Limit)) { - sql.Append(" LIMIT "); + sql.Append(separator).Append("LIMIT "); sql.Append(statement.Limit); } // OFFSET clause if (!string.IsNullOrEmpty(statement.Offset)) { - sql.Append(" OFFSET "); + sql.Append(separator).Append("OFFSET "); sql.Append(statement.Offset); } return sql.ToString(); } + /// + /// Statements are single-line, except that a WHERE holding a nested (multi-line) + /// subquery puts each clause on its own line, matching the other dialects' + /// subquery layout. Implements [LQL-SUBQUERY-LAYOUT]. + /// + private static string ClauseSeparator(SelectStatement statement) => + statement.WhereConditions.Any(c => + c is ExpressionCondition e && e.Expression.Contains('\n', StringComparison.Ordinal) + ) + ? "\n" + : " "; + /// /// Formats a column for SQL output /// diff --git a/Lql/Nimblesite.Lql.SQLite/SqlStatementExtensionsSQLite.cs b/Lql/Nimblesite.Lql.SQLite/SqlStatementExtensionsSQLite.cs index ba2fe08c..58dbd7f7 100644 --- a/Lql/Nimblesite.Lql.SQLite/SqlStatementExtensionsSQLite.cs +++ b/Lql/Nimblesite.Lql.SQLite/SqlStatementExtensionsSQLite.cs @@ -18,20 +18,21 @@ public static Result ToSQLite(this LqlStatement statement) { ArgumentNullException.ThrowIfNull(statement); - if (statement.ParseError != null) + if (LqlStatementValidator.Validate(statement) is SqlError error) { - return new Result.Error(statement.ParseError); - } - - if (statement.AstNode == null) - { - return new Result.Error( - new SqlError("No AST node found in statement") - ); + return new Result.Error(error); } try { + // Implements [LQL-CTE] and [LQL-DERIVED-TABLE]. + if (statement.AstNode is { } node && SubqueryLayout.Applies(node)) + { + return new Result.Ok( + SubqueryLayout.Render(node, SqlPaging.LimitOffset) + ); + } + if (statement.AstNode is Pipeline pipeline) { var sql = ConvertPipelineToSQLite(pipeline); diff --git a/Lql/Nimblesite.Lql.SqlServer/SqlServerContext.cs b/Lql/Nimblesite.Lql.SqlServer/SqlServerContext.cs index 5fd9614b..c64b752f 100644 --- a/Lql/Nimblesite.Lql.SqlServer/SqlServerContext.cs +++ b/Lql/Nimblesite.Lql.SqlServer/SqlServerContext.cs @@ -129,6 +129,17 @@ public string GenerateSQL() { var statement = _builder.Build(); + return GenerateStatementSQL(statement); + } + + /// + /// Generates SQL for an already-built statement, appending UNIONs + /// ([DP-SQL-MODEL-DIALECTS]) + /// + /// The SQL statement to generate from + /// The SQL string + private string GenerateStatementSQL(SelectStatement statement) + { var sql = GenerateSelectSQL(statement); // Add UNIONs (only for non-INSERT queries) @@ -141,6 +152,15 @@ public string GenerateSQL() return sql; } + /// + /// Generates SQL Server SQL directly from a SelectStatement. + /// Implements [DP-SQL-MODEL-DIALECTS]. + /// + /// The SQL statement to generate from + /// The SQL Server SQL string + public static string ToSqlServerSql(SelectStatement statement) => + new SqlServerContext().GenerateStatementSQL(statement); + /// /// Generates the SELECT portion of the SQL /// @@ -209,11 +229,8 @@ private static string GenerateSelectClause(SelectStatement statement) /// /// The SQL statement to generate from /// The FROM clause - private string GenerateFromClause(SelectStatement statement) + private static string GenerateFromClause(SelectStatement statement) { - if (_baseTable == null) - return "-- No base table"; - var baseTable = statement.Tables.Count > 0 ? statement.Tables.First() : null; if (baseTable == null) return "-- No base table"; @@ -255,15 +272,10 @@ private string GenerateFromClause(SelectStatement statement) /// /// The SQL statement to generate from /// The WHERE clause or empty string - private static string GenerateWhereClause(SelectStatement statement) - { - if (statement.WhereConditions.Count == 0) - return ""; - - var processedConditions = statement.WhereConditions.Select(GenerateWhereConditionSql); - - return $"WHERE {string.Join(" AND ", processedConditions)}"; - } + private static string GenerateWhereClause(SelectStatement statement) => + statement.WhereConditions.Count == 0 + ? "" + : $"WHERE {WhereConditionSql.Join(statement.WhereConditions, GenerateWhereConditionSql)}"; /// /// Generates SQL for a single WHERE condition diff --git a/Lql/Nimblesite.Lql.SqlServer/SqlStatementExtensionsSqlServer.cs b/Lql/Nimblesite.Lql.SqlServer/SqlStatementExtensionsSqlServer.cs index ca5cb2ea..66d4b564 100644 --- a/Lql/Nimblesite.Lql.SqlServer/SqlStatementExtensionsSqlServer.cs +++ b/Lql/Nimblesite.Lql.SqlServer/SqlStatementExtensionsSqlServer.cs @@ -17,20 +17,21 @@ public static Result ToSqlServer(this LqlStatement statement) { ArgumentNullException.ThrowIfNull(statement); - if (statement.ParseError != null) + if (LqlStatementValidator.Validate(statement) is SqlError error) { - return new Result.Error(statement.ParseError); - } - - if (statement.AstNode == null) - { - return new Result.Error( - new SqlError("No AST node found in statement") - ); + return new Result.Error(error); } try { + // Implements [LQL-CTE] and [LQL-DERIVED-TABLE]. + if (statement.AstNode is { } node && SubqueryLayout.Applies(node)) + { + return new Result.Ok( + SubqueryLayout.Render(node, SqlServerPaging) + ); + } + if (statement.AstNode is Pipeline pipeline) { var sql = ConvertPipelineToSqlServer(pipeline); @@ -58,4 +59,37 @@ public static string ConvertPipelineToSqlServer(Pipeline pipeline) var context = new SqlServerContext(); return PipelineProcessor.ConvertPipelineToSql(pipeline, context); } + + /// + /// Converts a Nimblesite.Sql.Model.SelectStatement to SQL Server syntax. + /// Implements [DP-SQL-MODEL-DIALECTS]. + /// + /// The SelectStatement to convert + /// A Result containing either SQL Server SQL string or a SqlError + public static Result ToSqlServer(this SelectStatement statement) + { + try + { + var sql = SqlServerContext.ToSqlServerSql(statement); + return new Result.Ok(sql); + } + catch (Exception ex) + { + return new Result.Error(SqlError.FromException(ex)); + } + } + + /// + /// SQL Server paging: TOP for a bare limit, OFFSET/FETCH once an offset is involved. + /// + private static SqlPaging SqlServerPaging(string? limit, string? offset) => + offset is null + ? new SqlPaging(limit is null ? "" : $"TOP {limit} ", []) + : new SqlPaging( + "", + [ + $"OFFSET {offset} ROWS", + .. limit is null ? Array.Empty() : [$"FETCH NEXT {limit} ROWS ONLY"], + ] + ); } diff --git a/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.Pipelines.cs b/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.Pipelines.cs new file mode 100644 index 00000000..77a3a2aa --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.Pipelines.cs @@ -0,0 +1,278 @@ +using Xunit; + +namespace Nimblesite.Lql.Tests; + +public sealed partial class LqlDialectE2ETests +{ + // Implements [LQL-PIPELINE-COMPOSITION]. + [Fact] + public void ProgressivePipeline_KeepsEarlierOperationsOnEveryDialect() + { + var browse = ConvertToAllDialects("users |> select(users.id, users.name)"); + var filtered = ConvertToAllDialects( + "users |> join(orders, on = users.id = orders.user_id) |> filter(fn(row) => row.orders.total > 100) |> select(users.id, orders.total)" + ); + var ranked = ConvertToAllDialects( + "users |> join(orders, on = users.id = orders.user_id) |> filter(fn(row) => row.orders.total > 100) |> order_by(orders.total desc) |> limit(50) |> select(users.id, orders.total)" + ); + + AssertProgressivePipeline(browse.PostgreSql, filtered.PostgreSql, ranked.PostgreSql); + AssertProgressivePipeline(browse.SQLite, filtered.SQLite, ranked.SQLite); + AssertProgressivePipeline(browse.SqlServer, filtered.SqlServer, ranked.SqlServer); + } + + private static void AssertProgressivePipeline(string browse, string filtered, string ranked) + { + Assert.Contains("FROM users", browse, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("JOIN", browse, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("WHERE", browse, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FROM users", filtered, StringComparison.OrdinalIgnoreCase); + Assert.Contains("JOIN orders", filtered, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHERE", filtered, StringComparison.OrdinalIgnoreCase); + Assert.Contains("100", filtered, StringComparison.Ordinal); + Assert.Contains("JOIN orders", ranked, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHERE", ranked, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", ranked, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DESC", ranked, StringComparison.OrdinalIgnoreCase); + Assert.Contains("50", ranked, StringComparison.Ordinal); + Assert.NotEqual(browse, filtered); + Assert.NotEqual(filtered, ranked); + } + + [Fact] + public void ComplexFilterWithAndOr_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + users + |> filter(fn(row) => row.users.age > 18 and row.users.country = 'US' or row.users.status = 'premium') + |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("OR", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void OrderByDescending_AllDialects_GeneratesDescSQL() + { + var lql = """ + users + |> order_by(users.age desc, users.name asc) + |> select(users.id, users.name, users.age) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DESC", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ASC", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void FilterWithStringLiteral_AllDialects_PreservesQuotes() + { + var lql = """ + users + |> filter(fn(row) => row.users.status = 'active') + |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("'active'", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void FilterWithNumericComparison_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + products + |> filter(fn(row) => row.products.price >= 100 and row.products.price <= 500) + |> select(products.id, products.name, products.price) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains(">=", sql, StringComparison.Ordinal); + Assert.Contains("<=", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void JoinWithFilterAndOrderBy_AllDialects_FullPipelineWorks() + { + var lql = """ + users + |> join(orders, on = users.id = orders.user_id) + |> filter(fn(row) => row.orders.total > 100) + |> order_by(orders.total desc) + |> limit(50) + |> select(users.name, orders.total, orders.status) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DESC", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void GroupByWithMultipleAggregates_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + orders + |> group_by(orders.status) + |> select( + orders.status, + count(*) as cnt, + sum(orders.total) as total, + avg(orders.total) as avg_val, + count(distinct orders.user_id) as unique_users + ) + |> order_by(total desc) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("COUNT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SUM", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AVG", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void SimpleTableReference_AllDialects_GeneratesSelectStar() + { + var lql = "users |> select(users.id, users.name)"; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FROM", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void ArithmeticExpressions_AllDialects_GeneratesCorrectSQL() + { + var lql = """ + products + |> select( + products.id, + products.price * products.quantity as total_value, + products.price + 10 as price_plus_ten + ) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("*", sql, StringComparison.Ordinal); + Assert.Contains("+", sql, StringComparison.Ordinal); + Assert.Contains("total_value", sql, StringComparison.Ordinal); + Assert.Contains("price_plus_ten", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void CaseExpression_AllDialects_GeneratesCaseWhenSQL() + { + var lql = """ + orders + |> select( + orders.id, + case + when orders.total > 1000 then orders.total * 0.95 + when orders.total > 500 then orders.total * 0.97 + else orders.total + end as discounted + ) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("CASE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHEN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("THEN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ELSE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("END", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void FilterWithNotEquals_AllDialects_GeneratesCorrectOperator() + { + var lql = """ + users + |> filter(fn(row) => row.users.status != 'deleted') + |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.True( + sql.Contains("!=", StringComparison.Ordinal) + || sql.Contains("<>", StringComparison.Ordinal), + "Should contain != or <> operator" + ); + } + } + + [Fact] + public void SelectWithColumnAlias_AllDialects_GeneratesAliasedColumns() + { + var lql = """ + users + |> select(users.id, users.name as full_name, users.email as contact_email) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + foreach (var sql in new[] { pg, ss, sl }) + { + Assert.Contains("full_name", sql, StringComparison.Ordinal); + Assert.Contains("contact_email", sql, StringComparison.Ordinal); + Assert.Contains("AS", sql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void LimitOnly_AllDialects_GeneratesLimitSQL() + { + var lql = """ + users |> limit(25) |> select(users.id, users.name) + """; + var (pg, ss, sl) = ConvertToAllDialects(lql); + + Assert.Contains("LIMIT", pg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LIMIT", sl, StringComparison.OrdinalIgnoreCase); + // SQL Server uses TOP + Assert.True( + ss.Contains("TOP", StringComparison.OrdinalIgnoreCase) + || ss.Contains("FETCH", StringComparison.OrdinalIgnoreCase), + "SQL Server should use TOP or FETCH for limit" + ); + } +} diff --git a/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.cs b/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.cs index 20c919c8..1df2be5d 100644 --- a/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlDialectE2ETests.cs @@ -11,8 +11,9 @@ namespace Nimblesite.Lql.Tests; /// E2E tests: LQL parse -> convert to all 3 SQL dialects -> verify output. /// Targets function mappings, DISTINCT, complex filters, and edge cases. /// -public sealed class LqlDialectE2ETests +public sealed partial class LqlDialectE2ETests { + // Implements [LQL-OUTPUT-DIALECTS]. private static (string PostgreSql, string SqlServer, string SQLite) ConvertToAllDialects( string lqlCode ) @@ -112,6 +113,9 @@ public void CountDistinctFunction_AllDialects_GeneratesCorrectSQL() { Assert.Contains("COUNT", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("DISTINCT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("COUNT(DISTINCT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("product_id", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("unique_products", sql, StringComparison.Ordinal); } } @@ -163,10 +167,20 @@ public void OffsetAndLimit_AllDialects_GeneratesCorrectPagination() Assert.Contains("OFFSET", pg, StringComparison.OrdinalIgnoreCase); Assert.Contains("LIMIT", pg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("20", pg, StringComparison.Ordinal); + Assert.Contains("10", pg, StringComparison.Ordinal); Assert.Contains("OFFSET", sl, StringComparison.OrdinalIgnoreCase); Assert.Contains("LIMIT", sl, StringComparison.OrdinalIgnoreCase); + Assert.Contains("20", sl, StringComparison.Ordinal); + Assert.Contains("10", sl, StringComparison.Ordinal); // SQL Server uses OFFSET...FETCH or TOP Assert.Contains("OFFSET", ss, StringComparison.OrdinalIgnoreCase); + Assert.Contains("20", ss, StringComparison.Ordinal); + Assert.Contains("10", ss, StringComparison.Ordinal); + Assert.True( + ss.Contains("FETCH", StringComparison.OrdinalIgnoreCase) + || ss.Contains("TOP", StringComparison.OrdinalIgnoreCase) + ); } [Fact] @@ -201,6 +215,9 @@ public void LeftJoin_AllDialects_GeneratesLeftJoinSQL() { Assert.Contains("LEFT", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("orders", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("user_id", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("total", sql, StringComparison.OrdinalIgnoreCase); } } @@ -220,243 +237,10 @@ public void MultipleJoins_AllDialects_GeneratesCorrectSQL() Assert.Contains("users", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("orders", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("products", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void ComplexFilterWithAndOr_AllDialects_GeneratesCorrectSQL() - { - var lql = """ - users - |> filter(fn(row) => row.users.age > 18 and row.users.country = 'US' or row.users.status = 'premium') - |> select(users.id, users.name) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("OR", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void OrderByDescending_AllDialects_GeneratesDescSQL() - { - var lql = """ - users - |> order_by(users.age desc, users.name asc) - |> select(users.id, users.name, users.age) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("DESC", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ASC", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void FilterWithStringLiteral_AllDialects_PreservesQuotes() - { - var lql = """ - users - |> filter(fn(row) => row.users.status = 'active') - |> select(users.id, users.name) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("'active'", sql, StringComparison.Ordinal); - Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void FilterWithNumericComparison_AllDialects_GeneratesCorrectSQL() - { - var lql = """ - products - |> filter(fn(row) => row.products.price >= 100 and row.products.price <= 500) - |> select(products.id, products.name, products.price) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains(">=", sql, StringComparison.Ordinal); - Assert.Contains("<=", sql, StringComparison.Ordinal); - Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void JoinWithFilterAndOrderBy_AllDialects_FullPipelineWorks() - { - var lql = """ - users - |> join(orders, on = users.id = orders.user_id) - |> filter(fn(row) => row.orders.total > 100) - |> order_by(orders.total desc) - |> limit(50) - |> select(users.name, orders.total, orders.status) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { + Assert.Contains("user_id", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("product_id", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("DESC", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void GroupByWithMultipleAggregates_AllDialects_GeneratesCorrectSQL() - { - var lql = """ - orders - |> group_by(orders.status) - |> select( - orders.status, - count(*) as cnt, - sum(orders.total) as total, - avg(orders.total) as avg_val, - count(distinct orders.user_id) as unique_users - ) - |> order_by(total desc) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("COUNT", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("SUM", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("AVG", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void SimpleTableReference_AllDialects_GeneratesSelectStar() - { - var lql = "users |> select(users.id, users.name)"; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("FROM", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void ArithmeticExpressions_AllDialects_GeneratesCorrectSQL() - { - var lql = """ - products - |> select( - products.id, - products.price * products.quantity as total_value, - products.price + 10 as price_plus_ten - ) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("*", sql, StringComparison.Ordinal); - Assert.Contains("+", sql, StringComparison.Ordinal); - Assert.Contains("total_value", sql, StringComparison.Ordinal); - Assert.Contains("price_plus_ten", sql, StringComparison.Ordinal); - } - } - - [Fact] - public void CaseExpression_AllDialects_GeneratesCaseWhenSQL() - { - var lql = """ - orders - |> select( - orders.id, - case - when orders.total > 1000 then orders.total * 0.95 - when orders.total > 500 then orders.total * 0.97 - else orders.total - end as discounted - ) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("CASE", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("WHEN", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("THEN", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ELSE", sql, StringComparison.OrdinalIgnoreCase); - Assert.Contains("END", sql, StringComparison.OrdinalIgnoreCase); - } - } - - [Fact] - public void FilterWithNotEquals_AllDialects_GeneratesCorrectOperator() - { - var lql = """ - users - |> filter(fn(row) => row.users.status != 'deleted') - |> select(users.id, users.name) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); - Assert.True( - sql.Contains("!=", StringComparison.Ordinal) - || sql.Contains("<>", StringComparison.Ordinal), - "Should contain != or <> operator" - ); - } - } - - [Fact] - public void SelectWithColumnAlias_AllDialects_GeneratesAliasedColumns() - { - var lql = """ - users - |> select(users.id, users.name as full_name, users.email as contact_email) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - foreach (var sql in new[] { pg, ss, sl }) - { - Assert.Contains("full_name", sql, StringComparison.Ordinal); - Assert.Contains("contact_email", sql, StringComparison.Ordinal); - Assert.Contains("AS", sql, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, sql.Split("JOIN", StringSplitOptions.None).Length - 1); } } - - [Fact] - public void LimitOnly_AllDialects_GeneratesLimitSQL() - { - var lql = """ - users |> limit(25) |> select(users.id, users.name) - """; - var (pg, ss, sl) = ConvertToAllDialects(lql); - - Assert.Contains("LIMIT", pg, StringComparison.OrdinalIgnoreCase); - Assert.Contains("LIMIT", sl, StringComparison.OrdinalIgnoreCase); - // SQL Server uses TOP - Assert.True( - ss.Contains("TOP", StringComparison.OrdinalIgnoreCase) - || ss.Contains("FETCH", StringComparison.OrdinalIgnoreCase), - "SQL Server should use TOP or FETCH for limit" - ); - } } diff --git a/Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs b/Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs index 9bd76647..7527d032 100644 --- a/Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs @@ -1,3 +1,6 @@ +using Nimblesite.Lql.Postgres; +using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; using Nimblesite.Sql.Model; using Outcome; using Xunit; @@ -193,21 +196,37 @@ public void InvalidFilterFunction_ShouldReturnError() Assert.NotNull(failure.Value.Position); } - [Fact] - public void UnderscorePipelineBase_ShouldParseAsTableName() + // Implements [LQL-IDENTIFIER-VALIDATION]. + [Theory] + [InlineData("tenant_members")] + [InlineData("fhir_patient")] + public void UnderscorePipelineBase_ShouldParseAsTableName(string tableName) { - // Arrange - const string lqlCode = """ - tenant_members |> select(id, name) - """; - - // Act - var result = LqlStatementConverter.ToStatement(lqlCode); - - // Assert - Assert.IsType.Ok>(result); - var success = (Result.Ok)result; - Assert.NotNull(success.Value); + var rejected = LqlStatementConverter.ToStatement("123_invalid_table |> select(id)"); + var error = Assert.IsType.Error>( + rejected + ); + Assert.Contains("identifier", error.Value.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(error.Value.Position); + + var accepted = LqlStatementConverter.ToStatement($"{tableName} |> select(id, name)"); + var statement = Assert + .IsType.Ok>(accepted) + .Value; + foreach ( + var result in new[] + { + statement.ToPostgreSql(), + statement.ToSqlServer(), + statement.ToSQLite(), + } + ) + { + var sql = Assert.IsType.Ok>(result).Value; + Assert.Contains(tableName, sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FROM", sql, StringComparison.OrdinalIgnoreCase); + } } [Fact] diff --git a/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs index 75a2556a..6fb0b303 100644 --- a/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.AdvancedQueries.cs @@ -11,11 +11,18 @@ public partial class LqlFileBasedTests [InlineData("window_function", "PostgreSql")] [InlineData("window_function", "SqlServer")] [InlineData("window_function", "SQLite")] - // TODO: Enable when parser supports these features: - // [InlineData("subquery_nested", "PostgreSql")] - Parenthesized pipeline expressions not yet supported - // [InlineData("cte_with", "PostgreSql")] - WITH clauses not yet supported - // [InlineData("exists_subquery", "PostgreSql")] - EXISTS subqueries not yet supported - // [InlineData("in_subquery", "PostgreSql")] - IN subqueries not yet supported + [InlineData("subquery_nested", "PostgreSql")] + [InlineData("subquery_nested", "SqlServer")] + [InlineData("subquery_nested", "SQLite")] + [InlineData("cte_with", "PostgreSql")] + [InlineData("cte_with", "SqlServer")] + [InlineData("cte_with", "SQLite")] + [InlineData("exists_subquery", "PostgreSql")] + [InlineData("exists_subquery", "SqlServer")] + [InlineData("exists_subquery", "SQLite")] + [InlineData("in_subquery", "PostgreSql")] + [InlineData("in_subquery", "SqlServer")] + [InlineData("in_subquery", "SQLite")] public void AdvancedQueries_FileBasedTest_ShouldTransformCorrectly( string testCaseName, string dialect diff --git a/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Joins.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Joins.cs index e8fa1227..50b0cc91 100644 --- a/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Joins.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.Joins.cs @@ -7,24 +7,6 @@ namespace Nimblesite.Lql.Tests; /// public partial class LqlFileBasedTests { - /* - TODO: many of these are not generating the correct SQL - Note that the SQL Server version used to look like this but something changed. - INSERT INTO report_table (users.id, users.name) -SELECT users.id, users.name -FROM ( - SELECT users.id, users.name - FROM users u - INNER JOIN orders o ON users.id = orders.user_id - WHERE orders.status = 'completed' - - UNION - - SELECT a.archived_users.id, a.archived_users.name - FROM archived_users a -) AS all_users - */ - [Theory] [InlineData("join_simple", "PostgreSql")] [InlineData("join_simple", "SqlServer")] diff --git a/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.cs b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.cs index 94ec442c..1d5b0af1 100644 --- a/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFileBasedTests.cs @@ -4,15 +4,24 @@ using Nimblesite.Sql.Model; using Outcome; using Xunit; +using LqlStatementOk = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Ok; +using SqlTextOk = Outcome.Result.Ok< + string, + Nimblesite.Sql.Model.SqlError +>; namespace Nimblesite.Lql.Tests; /// -/// File-based tests for LQL to PostgreSQL transformation. +/// File-based tests for LQL transformation on SQLite, PostgreSQL, and SQL Server. /// Tests read LQL input and expected SQL output from external files. /// public partial class LqlFileBasedTests { + // Implements [LQL-PIPELINE-COMPOSITION] for the shared file corpus. private static readonly string TestDataDirectory = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "TestData" @@ -22,7 +31,7 @@ public partial class LqlFileBasedTests /// Shared method to execute file-based tests /// /// Name of the test case - /// SQL dialect (PostgreSql or SqlServer) + /// SQL dialect private static void ExecuteFileBasedTest(string testCaseName, string dialect) { // Arrange @@ -42,58 +51,71 @@ private static void ExecuteFileBasedTest(string testCaseName, string dialect) string lqlCode = File.ReadAllText(lqlFile); string expectedSql = File.ReadAllText(expectedSqlFile).Trim(); + Assert.False(string.IsNullOrWhiteSpace(lqlCode), $"Empty LQL input: {testCaseName}"); + Assert.False( + string.IsNullOrWhiteSpace(expectedSql), + $"Expected SQL fixture {expectedSqlFile} must not be empty" + ); // Act var statementResult = LqlStatementConverter.ToStatement(lqlCode); - if (statementResult is Result.Error failure) - { - throw new InvalidOperationException( - $"Parsing failed for {testCaseName}: {failure.Value.DetailedMessage}" - ); - } - Assert.IsType.Ok>(statementResult); - var statement = ( - (Result.Ok)statementResult - ).Value; + var statement = Assert.IsType(statementResult).Value; + + var sqlResult = ToDialectSql(statement, dialect); + var actualSql = Assert.IsType(sqlResult).Value; + + Assert.False( + string.IsNullOrWhiteSpace(actualSql), + $"Transpiled SQL for {testCaseName} ({dialect}) must not be empty" + ); - Result sqlResult = dialect switch + Assert.Equal(expectedSql, actualSql); + } + + private static Result ToDialectSql(LqlStatement statement, string dialect) => + dialect switch { "PostgreSql" => statement.ToPostgreSql(), "SqlServer" => statement.ToSqlServer(), "SQLite" => statement.ToSQLite(), - _ => throw new ArgumentException($"Unsupported dialect: {dialect}"), + _ => UnknownDialect(dialect, statement), }; - Assert.IsType.Ok>(sqlResult); - var actualSql = ((Result.Ok)sqlResult).Value; - - if (expectedSql != actualSql) - { - Console.WriteLine($"=== TEST FAILURE: {testCaseName} ({dialect}) ==="); - Console.WriteLine($"Expected:\n{expectedSql}"); - Console.WriteLine($"Actual:\n{actualSql}"); - Console.WriteLine("==="); - } - Assert.Equal(expectedSql, actualSql); + private static Result UnknownDialect(string dialect, LqlStatement statement) + { + Assert.Fail($"Unsupported dialect: {dialect}"); + return statement.ToSQLite(); } + /// + /// Every LQL corpus input must have an expected SQL fixture for all three + /// supported dialects: PostgreSql, SqlServer, and SQLite. + /// [Fact] public void GetAllFileBasedTests_ShouldHaveMatchingFiles() { - // Arrange + // Arrange — fixture directories are part of the corpus and must exist. string lqlDirectory = Path.Combine(TestDataDirectory, "Lql"); string postgreSqlDirectory = Path.Combine(TestDataDirectory, "ExpectedSql", "PostgreSql"); string sqlServerDirectory = Path.Combine(TestDataDirectory, "ExpectedSql", "SqlServer"); + string sqliteDirectory = Path.Combine(TestDataDirectory, "ExpectedSql", "SQLite"); - if ( - !Directory.Exists(lqlDirectory) - || !Directory.Exists(postgreSqlDirectory) - || !Directory.Exists(sqlServerDirectory) - ) - { - // Skip test if directories don't exist yet - return; - } + Assert.True( + Directory.Exists(lqlDirectory), + $"LQL corpus directory {lqlDirectory} should exist" + ); + Assert.True( + Directory.Exists(postgreSqlDirectory), + $"PostgreSql fixture directory {postgreSqlDirectory} should exist" + ); + Assert.True( + Directory.Exists(sqlServerDirectory), + $"SqlServer fixture directory {sqlServerDirectory} should exist" + ); + Assert.True( + Directory.Exists(sqliteDirectory), + $"SQLite fixture directory {sqliteDirectory} should exist" + ); // Act var lqlFiles = Directory @@ -111,20 +133,35 @@ public void GetAllFileBasedTests_ShouldHaveMatchingFiles() .Select(f => Path.GetFileNameWithoutExtension(f)) .ToHashSet(); - // Assert + var sqliteFiles = Directory + .GetFiles(sqliteDirectory, "*.sql") + .Select(f => Path.GetFileNameWithoutExtension(f)) + .ToHashSet(); + + // Assert — exact fixture set equality for every dialect. Assert.True( lqlFiles.SetEquals(postgreSqlFiles), - "Every LQL test file should have a corresponding PostgreSQL expected SQL file" + $"Every LQL test file should have a corresponding PostgreSQL expected SQL file. LQL-only: {string.Join(", ", lqlFiles.Except(postgreSqlFiles))}; PostgreSQL-only: {string.Join(", ", postgreSqlFiles.Except(lqlFiles))}" ); Assert.True( lqlFiles.SetEquals(sqlServerFiles), - "Every LQL test file should have a corresponding SQL Server expected SQL file" + $"Every LQL test file should have a corresponding SQL Server expected SQL file. LQL-only: {string.Join(", ", lqlFiles.Except(sqlServerFiles))}; SQL Server-only: {string.Join(", ", sqlServerFiles.Except(lqlFiles))}" ); + + Assert.True( + lqlFiles.SetEquals(sqliteFiles), + $"Every LQL test file should have a corresponding SQLite expected SQL file. LQL-only: {string.Join(", ", lqlFiles.Except(sqliteFiles))}; SQLite-only: {string.Join(", ", sqliteFiles.Except(lqlFiles))}" + ); + + Assert.Equal(27, lqlFiles.Count); } - [Fact] - public void PerformanceTest_LargeQuery_ShouldCompleteWithinTimeLimit() + [Theory] + [InlineData("PostgreSql")] + [InlineData("SqlServer")] + [InlineData("SQLite")] + public void LargeQuery_TranspilesCompleteShapeForEveryDialect(string dialect) { // Arrange const string largeLqlCode = """ @@ -161,58 +198,45 @@ public void PerformanceTest_LargeQuery_ShouldCompleteWithinTimeLimit() |> limit(100) """; - // Act & Assert - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - var statementResult = LqlStatementConverter.ToStatement(largeLqlCode); - Assert.IsType.Ok>(statementResult); - var statement = ( - (Result.Ok)statementResult - ).Value; - - var sqlResult = statement.ToPostgreSql(); - Assert.IsType.Ok>(sqlResult); - - stopwatch.Stop(); - Assert.True( - stopwatch.ElapsedMilliseconds < 5000, - "Large LQL query transformation should complete within 5 seconds" - ); + var statement = Assert.IsType(statementResult).Value; + var sql = Assert.IsType(ToDialectSql(statement, dialect)).Value; + Assert.False(string.IsNullOrWhiteSpace(sql), dialect); + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("JOIN", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); } - [Fact] - public void StressTest_MultipleQueries_ShouldNotLeakMemory() + [Theory] + [InlineData("PostgreSql")] + [InlineData("SqlServer")] + [InlineData("SQLite")] + public void RepeatedQueries_GenerateStableSqlForEveryDialect(string dialect) { // Arrange const string lqlCode = """ users |> join(orders, on = users.id = orders.user_id) |> select(users.name, orders.total) """; - // Act & Assert + string? baseline = null; for (int i = 0; i < 1000; i++) { var statementResult = LqlStatementConverter.ToStatement(lqlCode); - Assert.IsType.Ok>( - statementResult - ); - var statement = ( - (Result.Ok)statementResult - ).Value; - - var sqlResult = statement.ToPostgreSql(); - Assert.IsType.Ok>(sqlResult); + var statement = Assert.IsType(statementResult).Value; + var sql = Assert.IsType(ToDialectSql(statement, dialect)).Value; + Assert.False(string.IsNullOrWhiteSpace(sql), dialect); + Assert.Contains("users", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("orders", sql, StringComparison.OrdinalIgnoreCase); + if (baseline is string original) + { + Assert.Equal(original, sql); + } + else + { + baseline = sql; + } } - - // Force garbage collection - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - // Memory usage should be reasonable after processing many queries - long memoryUsage = GC.GetTotalMemory(false); - Assert.True( - memoryUsage < 100_000_000, - "Memory usage should not exceed 100MB after processing 1000 queries" - ); + Assert.NotNull(baseline); } } diff --git a/Lql/Nimblesite.Lql.Tests/LqlFnCallInLambdaTests.cs b/Lql/Nimblesite.Lql.Tests/LqlFnCallInLambdaTests.cs index c1a50acd..4935081d 100644 --- a/Lql/Nimblesite.Lql.Tests/LqlFnCallInLambdaTests.cs +++ b/Lql/Nimblesite.Lql.Tests/LqlFnCallInLambdaTests.cs @@ -1,5 +1,8 @@ using Nimblesite.Lql.Postgres; +using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; using Nimblesite.Sql.Model; +using Outcome; using Xunit; namespace Nimblesite.Lql.Tests; @@ -17,60 +20,74 @@ namespace Nimblesite.Lql.Tests; /// public sealed class LqlFnCallInLambdaTests { - private static string ToPg(string lql) + private static string[] ToAllDialects(string lql) { - var stmt = LqlStatementConverter.ToStatement(lql); - Assert.True( - stmt is Outcome.Result.Ok, - stmt is Outcome.Result.Error e - ? e.Value.Message - : "expected Ok" + var parsed = Assert.IsType.Ok>( + LqlStatementConverter.ToStatement(lql) ); - var ok = (Outcome.Result.Ok)stmt; - var result = ok.Value.ToPostgreSql(); - Assert.True( - result is Outcome.Result.Ok, - result is Outcome.Result.Error e2 - ? e2.Value.Message - : "expected transpile Ok" - ); - return ((Outcome.Result.Ok)result).Value; + return + [ + Sql(parsed.Value.ToPostgreSql()), + Sql(parsed.Value.ToSqlServer()), + Sql(parsed.Value.ToSQLite()), + ]; } + private static string Sql(Result result) => + Assert.IsType.Ok>(result).Value; + [Fact] public void Lambda_BareFnCall_NoArgs_PassesThrough() { - var sql = ToPg("t |> filter(fn(x) => some_fn())"); - Assert.Contains("some_fn()", sql, StringComparison.Ordinal); + foreach (var sql in ToAllDialects("t |> filter(fn(x) => some_fn())")) + { + Assert.Contains("some_fn()", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FROM", sql, StringComparison.OrdinalIgnoreCase); + } } [Fact] public void Lambda_BareFnCall_StringArgs_PassesThrough() { - var sql = ToPg("t |> filter(fn(x) => is_member('a', 'b'))"); - Assert.Contains("is_member('a', 'b')", sql, StringComparison.Ordinal); + foreach (var sql in ToAllDialects("t |> filter(fn(x) => is_member('a', 'b'))")) + { + Assert.Contains("is_member('a', 'b')", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("is_member()", sql, StringComparison.Ordinal); + } } [Fact] public void Lambda_BareFnCall_QualifiedIdentArg_StripsLambdaPrefix() { // x is the lambda var -> x.tenant_id should emit as 'tenant_id'. - var sql = ToPg("t |> filter(fn(x) => is_member('u', x.tenant_id))"); - Assert.Contains("is_member('u', tenant_id)", sql, StringComparison.Ordinal); - Assert.DoesNotContain("x.tenant_id", sql, StringComparison.Ordinal); + foreach (var sql in ToAllDialects("t |> filter(fn(x) => is_member('u', x.tenant_id))")) + { + Assert.Contains("is_member('u', tenant_id)", sql, StringComparison.Ordinal); + Assert.DoesNotContain("x.tenant_id", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } } [Fact] public void Lambda_BareFnCall_NestedFnCallArg_PassesThrough() { - var sql = ToPg("t |> filter(fn(x) => is_member(app_user_id(), app_tenant_id()))"); - // Outer fn is emitted via ProcessFnCallExprToSql (lowercase preserved). - // Nested fn args go through ExtractFunctionCall which uppercases the - // function name -- Postgres treats unquoted names as case-insensitive - // so APP_USER_ID() and app_user_id() resolve to the same function. - Assert.Contains("is_member(", sql, StringComparison.Ordinal); - Assert.Contains("APP_USER_ID()", sql, StringComparison.Ordinal); - Assert.Contains("APP_TENANT_ID()", sql, StringComparison.Ordinal); + foreach ( + var sql in ToAllDialects( + "t |> filter(fn(x) => is_member(app_user_id(), app_tenant_id()))" + ) + ) + { + // Outer fn is emitted via ProcessFnCallExprToSql (lowercase preserved). + // Nested fn args go through ExtractFunctionCall which uppercases the + // function name -- Postgres treats unquoted names as case-insensitive + // so APP_USER_ID() and app_user_id() resolve to the same function. + Assert.Contains("is_member(", sql, StringComparison.Ordinal); + Assert.Contains("APP_USER_ID()", sql, StringComparison.Ordinal); + Assert.Contains("APP_TENANT_ID()", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } } [Fact] @@ -78,42 +95,98 @@ public void Lambda_AndCombinationWithBareFnCall_ParsesAndEmits() { // The right-hand side of AND is a bare fn call -- must not raise // 'Unsupported expr type in comparison'. - var sql = ToPg( - "t |> filter(fn(x) => x.id = '00000000-0000-0000-0000-000000000000' and is_member('u', x.tenant_id))" - ); - Assert.Contains("AND", sql, StringComparison.Ordinal); - Assert.Contains("is_member(", sql, StringComparison.Ordinal); + foreach ( + var sql in ToAllDialects( + "t |> filter(fn(x) => x.id = '00000000-0000-0000-0000-000000000000' and is_member('u', x.tenant_id))" + ) + ) + { + Assert.Contains("AND", sql, StringComparison.Ordinal); + Assert.Contains("is_member(", sql, StringComparison.Ordinal); + Assert.Contains("00000000-0000-0000-0000-000000000000", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } } [Fact] public void Lambda_OrCombinationWithBareFnCall_ParsesAndEmits() { - var sql = ToPg( - "t |> filter(fn(x) => x.id = '00000000-0000-0000-0000-000000000000' or is_member('u', x.tenant_id))" - ); - Assert.Contains("OR", sql, StringComparison.Ordinal); - Assert.Contains("is_member(", sql, StringComparison.Ordinal); + foreach ( + var sql in ToAllDialects( + "t |> filter(fn(x) => x.id = '00000000-0000-0000-0000-000000000000' or is_member('u', x.tenant_id))" + ) + ) + { + Assert.Contains("OR", sql, StringComparison.Ordinal); + Assert.Contains("is_member(", sql, StringComparison.Ordinal); + Assert.Contains("00000000-0000-0000-0000-000000000000", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + } } [Fact] public void Lambda_BareFnCall_IntArg_PassesThrough() { - var sql = ToPg("t |> filter(fn(x) => has_role(42))"); - Assert.Contains("has_role(42)", sql, StringComparison.Ordinal); + foreach (var sql in ToAllDialects("t |> filter(fn(x) => has_role(42))")) + { + Assert.Contains("has_role(42)", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("has_role()", sql, StringComparison.Ordinal); + } } [Fact] public void Lambda_BareFnCall_DecimalArg_PassesThrough() { - var sql = ToPg("t |> filter(fn(x) => has_balance(1.5))"); - Assert.Contains("has_balance(1.5)", sql, StringComparison.Ordinal); + foreach (var sql in ToAllDialects("t |> filter(fn(x) => has_balance(1.5))")) + { + Assert.Contains("has_balance(1.5)", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("has_balance()", sql, StringComparison.Ordinal); + } } [Fact] public void Lambda_BareFnCall_IdentArg_PassesThrough() { - var sql = ToPg("t |> filter(fn(x) => some_fn(other_col))"); - Assert.Contains("some_fn(", sql, StringComparison.Ordinal); - Assert.Contains("other_col", sql, StringComparison.Ordinal); + foreach (var sql in ToAllDialects("t |> filter(fn(x) => some_fn(other_col))")) + { + Assert.Contains("some_fn(", sql, StringComparison.Ordinal); + Assert.Contains("other_col", sql, StringComparison.Ordinal); + Assert.Contains("WHERE", sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("some_fn()", sql, StringComparison.Ordinal); + } + } + + // Implements [LQL-PREDICATE-IN-LIST]. + [Theory] + [InlineData("'owner', 'admin'", "'owner', 'admin', 'auditor'")] + [InlineData("1, 2", "1, 2, 3")] + public void InList_ChangingMembership_PreservesExactPredicateAcrossDialects( + string initialMembers, + string updatedMembers + ) + { + var initial = ToAllDialects($"members |> filter(fn(m) => m.role in ({initialMembers}))"); + var updated = ToAllDialects($"members |> filter(fn(m) => m.role in ({updatedMembers}))"); + + for (var dialect = 0; dialect < initial.Length; dialect++) + { + Assert.Contains( + $"role IN ({initialMembers})", + initial[dialect], + StringComparison.OrdinalIgnoreCase + ); + Assert.Contains( + $"role IN ({updatedMembers})", + updated[dialect], + StringComparison.OrdinalIgnoreCase + ); + Assert.Contains("WHERE", initial[dialect], StringComparison.OrdinalIgnoreCase); + Assert.Contains("WHERE", updated[dialect], StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(" OR ", initial[dialect], StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(" OR ", updated[dialect], StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(initial[dialect], updated[dialect]); + } } } diff --git a/Lql/Nimblesite.Lql.Tests/LqlSubqueryLayoutTests.cs b/Lql/Nimblesite.Lql.Tests/LqlSubqueryLayoutTests.cs new file mode 100644 index 00000000..cd448137 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/LqlSubqueryLayoutTests.cs @@ -0,0 +1,131 @@ +using Nimblesite.Lql.Postgres; +using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; +using Nimblesite.Sql.Model; +using Outcome; +using Xunit; +using LqlStatementError = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Error; +using LqlStatementOk = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Ok; +using SqlTextOk = Outcome.Result.Ok< + string, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.Lql.Tests; + +/// +/// CTEs, derived-table joins and EXISTS/IN bodies share one layout on every dialect; +/// only paging differs. Tests [LQL-CTE], [LQL-DERIVED-TABLE] and [LQL-SUBQUERY-LAYOUT]. +/// +public sealed class LqlSubqueryLayoutTests +{ + [Theory] + [InlineData("SQLite")] + [InlineData("PostgreSql")] + [InlineData("SqlServer")] + public void ExistsBody_WithTwoFilters_KeepsBothAndGroupsEach(string dialect) => + Assert.Equal( + """ + SELECT users.id + FROM users + WHERE EXISTS ( + SELECT 1 + FROM orders + WHERE (orders.user_id = users.id) AND (orders.total > 100 OR orders.vip = 1) + ) + """, + Transpile( + "users |> filter(fn(row) => exists(orders |> filter(fn(o) => o.orders.user_id = row.users.id) |> filter(fn(o) => o.orders.total > 100 or o.orders.vip = 1))) |> select(users.id)", + dialect + ) + ); + + [Theory] + [InlineData("SQLite", "", "ORDER BY a.id ASC\nLIMIT 5")] + [InlineData("PostgreSql", "", "ORDER BY a.id ASC\nLIMIT 5")] + [InlineData("SqlServer", "TOP 5 ", "ORDER BY a.id ASC")] + public void TwoCtes_JoinedAndPaged_RenderPerDialectPaging( + string dialect, + string selectPrefix, + string tail + ) => + Assert.Equal( + $""" + WITH active AS ( + SELECT id + FROM users + WHERE active = 1 + ), + big AS ( + SELECT user_id + FROM orders + WHERE total > 500 + ) + SELECT {selectPrefix}a.id + FROM active a + INNER JOIN big b ON a.id = b.user_id + {tail} + """, + Transpile( + "with active as (users |> filter(fn(u) => u.users.active = 1) |> select(users.id)), big as (orders |> filter(fn(o) => o.orders.total > 500) |> select(orders.user_id)) active |> join(big, on = active.id = big.user_id) |> select(active.id) |> order_by(active.id asc) |> limit(5)", + dialect + ) + ); + + [Theory] + [InlineData("SQLite", "LIMIT 5\nOFFSET 10")] + [InlineData("PostgreSql", "LIMIT 5\nOFFSET 10")] + [InlineData("SqlServer", "OFFSET 10 ROWS\nFETCH NEXT 5 ROWS ONLY")] + public void DerivedTableJoin_WithOffsetAndLimit_RendersPagingAfterJoin( + string dialect, + string paging + ) => + Assert.Equal( + $""" + SELECT u.id + FROM users u + INNER JOIN ( + SELECT user_id + FROM orders + ) o ON u.id = o.user_id + {paging} + """, + Transpile( + "users |> join((orders |> select(orders.user_id)), on = users.id = orders.user_id) |> select(users.id) |> offset(10) |> limit(5)", + dialect + ) + ); + + [Fact] + public void ExistsBody_WithLimit_IsRejectedInsteadOfDropped() + { + var error = Assert.IsType( + LqlStatementConverter.ToStatement( + "users |> filter(fn(row) => exists(orders |> limit(1))) |> select(users.id)" + ) + ); + Assert.Contains( + "limit/offset are not supported inside EXISTS or IN subqueries", + error.Value.Message, + StringComparison.Ordinal + ); + } + + private static string Transpile(string lql, string dialect) + { + var statement = Assert.IsType(LqlStatementConverter.ToStatement(lql)).Value; + Result sql = dialect switch + { + "PostgreSql" => statement.ToPostgreSql(), + "SqlServer" => statement.ToSqlServer(), + _ => statement.ToSQLite(), + }; + return Assert.IsType(sql).Value; + } +} diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/cte_with.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/cte_with.sql new file mode 100644 index 00000000..0aa69e25 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/cte_with.sql @@ -0,0 +1,9 @@ +WITH high_value_customers AS ( + SELECT user_id + FROM orders + GROUP BY user_id + HAVING SUM(total) > 10000 +) +SELECT u.id, u.name, u.email +FROM users u +INNER JOIN high_value_customers hvc ON u.id = hvc.user_id diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/exists_subquery.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/exists_subquery.sql new file mode 100644 index 00000000..f67f5ea0 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/exists_subquery.sql @@ -0,0 +1,7 @@ +SELECT users.id, users.name +FROM users +WHERE EXISTS ( + SELECT 1 + FROM orders + WHERE orders.user_id = users.id AND orders.status = 'completed' +) diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/in_subquery.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/in_subquery.sql new file mode 100644 index 00000000..32c284e3 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/in_subquery.sql @@ -0,0 +1,7 @@ +SELECT users.id, users.name +FROM users +WHERE users.id IN ( + SELECT orders.user_id + FROM orders + WHERE orders.status = 'completed' +) diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/subquery_nested.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/subquery_nested.sql new file mode 100644 index 00000000..2b4f3609 --- /dev/null +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/SQLite/subquery_nested.sql @@ -0,0 +1,8 @@ +SELECT u.name, o.order_count +FROM users u +INNER JOIN ( + SELECT user_id, COUNT(*) AS order_count + FROM orders + WHERE status = 'completed' + GROUP BY user_id +) o ON u.id = o.user_id diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj index 9a7bd145..f3c876d2 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/Nimblesite.Lql.TypeProvider.FSharp.Tests.fsproj @@ -55,6 +55,14 @@ PreserveNewest + + TestData/%(Filename)%(Extension) + PreserveNewest + + + TestData/%(Filename)%(Extension) + PreserveNewest + diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/RuntimeTranspilerTests.fs b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/RuntimeTranspilerTests.fs index b2468d84..1ef8f104 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/RuntimeTranspilerTests.fs +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/RuntimeTranspilerTests.fs @@ -1,5 +1,7 @@ module Nimblesite.Lql.Core.TypeProvider.RuntimeTranspilerTests +open System +open System.IO open Xunit open Nimblesite.Lql.Core open Nimblesite.Lql.SQLite @@ -239,6 +241,48 @@ type RuntimeComplexPipelineTests() = assertContainsCi "SUM" sql assertContainsCi "AVG" sql +[] +type RuntimeDistinctAndPredicateTests() = + + [] + member _.``Runtime: select_distinct renders SELECT DISTINCT``() = + Assert.Equal( + "SELECT DISTINCT users.country FROM users", + transpile "users |> select_distinct(users.country)" + ) + + [] + member _.``Runtime: null check and comparison are joined with AND``() = + Assert.Equal( + "SELECT users.id FROM users WHERE users.email IS NOT NULL AND users.age >= 18", + transpile + "users |> filter(fn(row) => row.users.email is not null and row.users.age >= 18) |> select(users.id)" + ) + + [] + member _.``Runtime: parenthesized OR keeps its grouping inside AND``() = + Assert.Equal( + "SELECT users.id FROM users WHERE (users.age < 18 OR users.age > 65) AND users.active = 1", + transpile + "users |> filter(fn(row) => (row.users.age < 18 or row.users.age > 65) and row.users.active = 1) |> select(users.id)" + ) + +// Implements [LQL-CTE], [LQL-DERIVED-TABLE], and [LQL-SUBQUERY-LAYOUT] +// through the F# runtime path, sharing the canonical query/SQL fixtures. +[] +type RuntimeSubqueryTests() = + + [] + [] + [] + [] + [] + member _.``Runtime: subqueries match the shared SQLite golden SQL``(name: string) = + let path extension = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestData", name + extension) + let expected = File.ReadAllText(path ".sql").Trim() + Assert.Equal(expected, File.ReadAllText(path ".lql") |> transpile) + [] type RuntimeParseErrorTests() = diff --git a/Lql/lql-lsp-rust/Cargo.lock b/Lql/lql-lsp-rust/Cargo.lock index b19b4908..d98934e9 100644 --- a/Lql/lql-lsp-rust/Cargo.lock +++ b/Lql/lql-lsp-rust/Cargo.lock @@ -90,18 +90,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -118,7 +118,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -211,11 +211,11 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -292,6 +292,15 @@ dependencies = [ "time", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -333,7 +342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", "rand_core 0.10.1", ] @@ -349,6 +358,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -377,30 +398,29 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] -name = "cpufeatures" -version = "0.3.0" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "libc", + "hybrid-array", ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "generic-array", - "typenum", + "cmov", ] [[package]] @@ -423,7 +443,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -434,7 +454,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -462,13 +482,14 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", - "subtle", + "ctutils", ] [[package]] @@ -479,7 +500,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -683,7 +704,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -715,16 +736,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -834,9 +845,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ "digest", ] @@ -895,6 +906,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1296,7 +1316,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lql-analyzer" -version = "0.1.0" +version = "0.0.0-dev" dependencies = [ "antlr-rust", "lql-parser", @@ -1304,8 +1324,9 @@ dependencies = [ [[package]] name = "lql-lsp" -version = "0.1.0" +version = "0.0.0-dev" dependencies = [ + "futures", "lql-analyzer", "lql-parser", "reqwest", @@ -1321,7 +1342,7 @@ dependencies = [ [[package]] name = "lql-parser" -version = "0.1.0" +version = "0.0.0-dev" dependencies = [ "antlr-rust", ] @@ -1347,9 +1368,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", "digest", @@ -1530,7 +1551,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1621,7 +1642,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn", + "syn 2.0.117", ] [[package]] @@ -1666,7 +1687,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1695,9 +1716,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "postgres-protocol" -version = "0.6.10" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64", "byteorder", @@ -1706,16 +1727,16 @@ dependencies = [ "hmac", "md-5", "memchr", - "rand 0.9.2", + "rand 0.10.1", "sha2", "stringprep", ] [[package]] name = "postgres-types" -version = "0.2.12" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "bytes", "fallible-iterator 0.2.0", @@ -1753,7 +1774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1785,7 +1806,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1899,7 +1920,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2172,7 +2193,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2196,7 +2217,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2213,11 +2234,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.19.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -2232,24 +2254,24 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.19.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -2329,7 +2351,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn", + "syn 2.0.117", ] [[package]] @@ -2340,7 +2362,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2360,6 +2382,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2377,7 +2410,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2408,7 +2441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2471,7 +2504,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2555,7 +2588,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2570,9 +2603,9 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.16" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -2587,7 +2620,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.2", + "rand 0.10.1", "socket2", "tokio", "tokio-util", @@ -2756,7 +2789,7 @@ checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2784,7 +2817,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2810,9 +2843,9 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-bidi" @@ -3020,7 +3053,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3143,7 +3176,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3154,7 +3187,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3378,7 +3411,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3394,7 +3427,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3471,7 +3504,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3492,7 +3525,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3512,7 +3545,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3552,7 +3585,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Lql/lql-lsp-rust/Cargo.toml b/Lql/lql-lsp-rust/Cargo.toml index 02274740..9cb47aa5 100644 --- a/Lql/lql-lsp-rust/Cargo.toml +++ b/Lql/lql-lsp-rust/Cargo.toml @@ -7,7 +7,8 @@ members = [ ] [workspace.package] -version = "0.1.0" +# Placeholder; release stamps the tag version in the runner tree. [SWR-VERSION-BUILD-STAMPING] +version = "0.0.0-dev" edition = "2021" license = "MIT" diff --git a/Lql/lql-lsp-rust/Claude.md b/Lql/lql-lsp-rust/Claude.md new file mode 100644 index 00000000..1871289a --- /dev/null +++ b/Lql/lql-lsp-rust/Claude.md @@ -0,0 +1,20 @@ +# lql-lsp-rust — AI guidance + +## Migration to `lspkit` + +The cross-cutting LSP scaffolding in this repo (tower-lsp boilerplate, document store, diagnostics pipeline, init-options config) is being distilled into the generic `lspkit-*` workspace at `/Users/christianfindlay/Documents/Code/lsp_toolkit`. + +**For new LSP infrastructure work:** prefer `lspkit-*` crates over reinventing it here. +**For changes to existing scaffolding in this repo:** flag in the PR description if the patch duplicates `lspkit` functionality, and reference the upstream crate. + +Mapping (current → toolkit crate): + +| Current file | Toolkit crate | +|---|---| +| `crates/lql-lsp/src/main.rs` document `HashMap` (lines 19–38, 310–337) | `lspkit-vfs` (`Vfs`, `DocumentUri`, incremental edits) | +| `crates/lql-lsp/src/main.rs` `tower-lsp` setup (lines 633–649, 206–245) | `lspkit-server` (hand-rolled JSON-RPC + `Dispatcher` + `Capabilities`) — **note:** toolkit does not depend on `tower-lsp` (unmaintained) | +| `crates/lql-lsp/src/main.rs` diagnostics collection (lines 45–92) | `lspkit-server::diagnostics::DiagnosticsBus` | +| `crates/lql-lsp/src/main.rs` `initializationOptions` parsing (lines 207–220) | `lspkit-config::load_from_ancestor` (file-backed) or consumer code reading init options | +| `crates/lql-lsp/tests/lsp_protocol.rs` LSP harness | (not yet in toolkit; harness crate is a v0.1 follow-up) | + +Code in this repo is **not** being removed — it stays canonical until the toolkit matures. This note exists so future agents reuse `lspkit` for new servers and avoid widening this repo's scaffolding. diff --git a/Lql/lql-lsp-rust/build-vsix.sh b/Lql/lql-lsp-rust/build-vsix.sh old mode 100755 new mode 100644 diff --git a/Lql/lql-lsp-rust/crates/lql-analyzer/src/completion.rs b/Lql/lql-lsp-rust/crates/lql-analyzer/src/completion.rs index 31b1afc2..145ebc60 100644 --- a/Lql/lql-lsp-rust/crates/lql-analyzer/src/completion.rs +++ b/Lql/lql-lsp-rust/crates/lql-analyzer/src/completion.rs @@ -249,6 +249,27 @@ fn pipeline_completions(prefix: &str) -> Vec { .collect() } +fn build_completions( + entries: Vec<(&str, &str, &str)>, + prefix: &str, + kind: CompletionKind, + with_snippet: bool, + sort_priority: u8, +) -> Vec { + entries + .into_iter() + .filter(|(name, _, _)| name.starts_with(prefix)) + .map(|(name, detail, doc)| CompletionItem { + label: name.into(), + kind, + detail: detail.into(), + documentation: doc.into(), + insert_text: with_snippet.then(|| format!("{name}($0)")), + sort_priority, + }) + .collect() +} + fn aggregate_completions(prefix: &str) -> Vec { let fns = vec![ ("count", "Count rows", "count(*) or count(column)"), @@ -261,17 +282,7 @@ fn aggregate_completions(prefix: &str) -> Vec { ("dense_rank", "Dense rank", "dense_rank() over (...)"), ]; - fns.into_iter() - .filter(|(name, _, _)| name.starts_with(prefix)) - .map(|(name, detail, doc)| CompletionItem { - label: name.into(), - kind: CompletionKind::Function, - detail: detail.into(), - documentation: doc.into(), - insert_text: Some(format!("{name}($0)")), - sort_priority: 2, - }) - .collect() + build_completions(fns, prefix, CompletionKind::Function, true, 2) } fn string_function_completions(prefix: &str) -> Vec { @@ -291,17 +302,7 @@ fn string_function_completions(prefix: &str) -> Vec { ("coalesce", "First non-null", "coalesce(val1, val2, ...)"), ]; - fns.into_iter() - .filter(|(name, _, _)| name.starts_with(prefix)) - .map(|(name, detail, doc)| CompletionItem { - label: name.into(), - kind: CompletionKind::Function, - detail: detail.into(), - documentation: doc.into(), - insert_text: Some(format!("{name}($0)")), - sort_priority: 2, - }) - .collect() + build_completions(fns, prefix, CompletionKind::Function, true, 2) } fn keyword_completions(prefix: &str) -> Vec { @@ -319,17 +320,7 @@ fn keyword_completions(prefix: &str) -> Vec { ("distinct", "Distinct modifier", "Remove duplicates"), ]; - kws.into_iter() - .filter(|(name, _, _)| name.starts_with(prefix)) - .map(|(name, detail, doc)| CompletionItem { - label: name.into(), - kind: CompletionKind::Keyword, - detail: detail.into(), - documentation: doc.into(), - insert_text: None, - sort_priority: 3, - }) - .collect() + build_completions(kws, prefix, CompletionKind::Keyword, false, 3) } fn lambda_completions(prefix: &str) -> Vec { @@ -343,17 +334,7 @@ fn lambda_completions(prefix: &str) -> Vec { ("exists", "EXISTS subquery", "Check subquery has results"), ]; - ops.into_iter() - .filter(|(name, _, _)| name.starts_with(prefix)) - .map(|(name, detail, doc)| CompletionItem { - label: name.into(), - kind: CompletionKind::Keyword, - detail: detail.into(), - documentation: doc.into(), - insert_text: None, - sort_priority: 3, - }) - .collect() + build_completions(ops, prefix, CompletionKind::Keyword, false, 3) } #[cfg(test)] diff --git a/Lql/lql-lsp-rust/crates/lql-analyzer/src/scope.rs b/Lql/lql-lsp-rust/crates/lql-analyzer/src/scope.rs index b425b6f8..c0a99ecb 100644 --- a/Lql/lql-lsp-rust/crates/lql-analyzer/src/scope.rs +++ b/Lql/lql-lsp-rust/crates/lql-analyzer/src/scope.rs @@ -1,7 +1,8 @@ -use antlr_rust::tree::ParseTree; +use antlr_rust::tree::{ParseTree, TerminalNode}; use lql_parser::{ - parse_lql, ArgContextAttrs, ArgListContextAttrs, ExprContextAttrs, FunctionCallContextAttrs, - LetStmtContextAttrs, PipeExprContextAttrs, ProgramContextAttrs, StatementContextAttrs, + parse_lql, ArgContextAttrs, ArgListContextAttrs, CteDefContextAttrs, ExprContextAttrs, + FunctionCallContextAttrs, LetStmtContextAttrs, LqlParserContextType, PipeExprContextAttrs, + ProgramContextAttrs, StatementContextAttrs, WithStmtContextAttrs, }; use std::collections::HashMap; @@ -71,10 +72,7 @@ pub fn build_scope(source: &str) -> ScopeMap { // Extract let bindings from letStmt nodes if let Some(let_stmt) = stmt.letStmt() { if let Some(ident) = let_stmt.IDENT() { - let name = ident.symbol.text.to_string(); - let line = (ident.symbol.line - 1) as u32; - let col = ident.symbol.column as u32; - scope.add_binding(name, line, col); + add_ident_binding(&ident, &mut scope); } // Collect table references from the let statement's pipe expression if let Some(pipe_expr) = let_stmt.pipeExpr() { @@ -82,6 +80,21 @@ pub fn build_scope(source: &str) -> ScopeMap { } } + // CTE names bind like `let` names; their bodies and the main pipeline reference tables. + if let Some(with_stmt) = stmt.withStmt() { + for cte in with_stmt.cteDef_all() { + if let Some(ident) = cte.IDENT() { + add_ident_binding(&ident, &mut scope); + } + if let Some(pipe_expr) = cte.pipeExpr() { + collect_tables_from_pipe(&pipe_expr, &mut scope); + } + } + if let Some(pipe_expr) = with_stmt.pipeExpr() { + collect_tables_from_pipe(&pipe_expr, &mut scope); + } + } + // Collect table references from top-level pipe expressions if let Some(pipe_expr) = stmt.pipeExpr() { collect_tables_from_pipe(&pipe_expr, &mut scope); @@ -91,6 +104,14 @@ pub fn build_scope(source: &str) -> ScopeMap { scope } +/// Register the binding named by an IDENT token at its source position. +fn add_ident_binding(ident: &TerminalNode<'_, LqlParserContextType>, scope: &mut ScopeMap) { + let name = ident.symbol.text.to_string(); + let line = (ident.symbol.line - 1) as u32; + let col = ident.symbol.column as u32; + scope.add_binding(name, line, col); +} + /// Collect table names from a pipe expression. /// The first expr in a pipeline that is a bare IDENT (no parens, no dot) is a table reference. /// Also recurses into function call arguments to find table references (e.g. join(orders, ...)). @@ -156,6 +177,18 @@ pub fn collect_function_calls(source: &str) -> Vec { collect_fn_calls_from_pipe(&pipe_expr, &mut calls); } } + if let Some(with_stmt) = stmt.withStmt() { + for pipe_expr in with_stmt + .cteDef_all() + .iter() + .filter_map(|cte| cte.pipeExpr()) + { + collect_fn_calls_from_pipe(&pipe_expr, &mut calls); + } + if let Some(pipe_expr) = with_stmt.pipeExpr() { + collect_fn_calls_from_pipe(&pipe_expr, &mut calls); + } + } if let Some(pipe_expr) = stmt.pipeExpr() { collect_fn_calls_from_pipe(&pipe_expr, &mut calls); } @@ -174,6 +207,36 @@ fn collect_fn_calls_from_pipe<'input>( } } +/// Build a `FunctionCallInfo` from an IDENT terminal node and append it to `calls`. +fn push_fn_call<'input>( + ident: &TerminalNode<'input, LqlParserContextType>, + calls: &mut Vec, +) { + let name = ident.symbol.text.to_string(); + let line = (ident.symbol.line - 1) as u32; + let col = ident.symbol.column as u32; + let end_col = col + name.len() as u32; + calls.push(FunctionCallInfo { + name, + line, + col, + end_col, + }); +} + +/// Collect function calls from a single `functionCall` node (its IDENT plus nested argList). +fn collect_fn_calls_from_function_call<'input>( + fc: &lql_parser::FunctionCallContext<'input>, + calls: &mut Vec, +) { + if let Some(ident) = fc.IDENT() { + push_fn_call(&ident, calls); + } + if let Some(inner_args) = fc.argList() { + collect_fn_calls_from_arg_list(&inner_args, calls); + } +} + /// Collect function calls from an expr node. fn collect_fn_calls_from_expr<'input>( expr: &lql_parser::ExprContext<'input>, @@ -182,16 +245,7 @@ fn collect_fn_calls_from_expr<'input>( // expr with IDENT + argList is a function call form if let Some(ident) = expr.IDENT() { if expr.argList().is_some() { - let name = ident.symbol.text.to_string(); - let line = (ident.symbol.line - 1) as u32; - let col = ident.symbol.column as u32; - let end_col = col + name.len() as u32; - calls.push(FunctionCallInfo { - name, - line, - col, - end_col, - }); + push_fn_call(&ident, calls); } } @@ -214,43 +268,14 @@ fn collect_fn_calls_from_arg_list<'input>( for arg in arg_list.arg_all() { // Direct functionCall rule in arg if let Some(fc) = arg.functionCall() { - if let Some(ident) = fc.IDENT() { - let name = ident.symbol.text.to_string(); - let line = (ident.symbol.line - 1) as u32; - let col = ident.symbol.column as u32; - let end_col = col + name.len() as u32; - calls.push(FunctionCallInfo { - name, - line, - col, - end_col, - }); - } - // Recurse into functionCall's own argList - if let Some(inner_args) = fc.argList() { - collect_fn_calls_from_arg_list(&inner_args, calls); - } + collect_fn_calls_from_function_call(&fc, calls); } // columnAlias may contain a functionCall if let Some(col_alias) = arg.columnAlias() { use lql_parser::ColumnAliasContextAttrs; if let Some(fc) = col_alias.functionCall() { - if let Some(ident) = fc.IDENT() { - let name = ident.symbol.text.to_string(); - let line = (ident.symbol.line - 1) as u32; - let col = ident.symbol.column as u32; - let end_col = col + name.len() as u32; - calls.push(FunctionCallInfo { - name, - line, - col, - end_col, - }); - } - if let Some(inner_args) = fc.argList() { - collect_fn_calls_from_arg_list(&inner_args, calls); - } + collect_fn_calls_from_function_call(&fc, calls); } } @@ -343,6 +368,31 @@ mod tests { // ── build_scope tests ── + #[test] + fn test_build_scope_cte_names_are_bindings() { + let scope = build_scope( + "with big as (orders |> filter(fn(o) => o.orders.total > 5)), \ + vip as (users |> select(users.id)) \ + big |> join(vip, on = big.user_id = vip.id) |> select(big.user_id)", + ); + assert!(scope.has_binding("big")); + assert!(scope.has_binding("vip")); + let binding = scope.get_binding("big").expect("cte binding"); + assert_eq!((binding.line, binding.col), (0, 5)); + assert!(scope.table_names().contains(&"orders")); + assert!(scope.table_names().contains(&"users")); + } + + #[test] + fn test_collect_function_calls_includes_cte_bodies_and_main_pipeline() { + let calls = collect_function_calls( + "with big as (orders |> filter(fn(o) => o.orders.total > 5)) big |> select(big.user_id)", + ); + let names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect(); + assert!(names.contains(&"filter"), "{names:?}"); + assert!(names.contains(&"select"), "{names:?}"); + } + #[test] fn test_build_scope_let_binding() { let scope = build_scope("let x = users |> select(users.id)"); diff --git a/Lql/lql-lsp-rust/crates/lql-analyzer/src/tests.rs b/Lql/lql-lsp-rust/crates/lql-analyzer/src/tests.rs index a038f294..52ecd036 100644 --- a/Lql/lql-lsp-rust/crates/lql-analyzer/src/tests.rs +++ b/Lql/lql-lsp-rust/crates/lql-analyzer/src/tests.rs @@ -1,12 +1,19 @@ #[cfg(test)] mod diagnostics_tests { - use crate::diagnostics::{analyze, DiagnosticSeverity}; + use crate::diagnostics::{analyze, Diagnostic, DiagnosticSeverity}; use crate::scope::ScopeMap; fn empty_scope() -> ScopeMap { ScopeMap::new() } + fn diags_matching<'a>(diags: &'a [Diagnostic], needle: &str) -> Vec<&'a Diagnostic> { + diags + .iter() + .filter(|d| d.message.contains(needle)) + .collect() + } + // ── analyze: empty / comments ────────────────────────────────────── #[test] fn empty_source_produces_no_diagnostics() { @@ -30,20 +37,14 @@ mod diagnostics_tests { #[test] fn correct_pipe_spacing_no_warning() { let diags = analyze("users |> select(users.id)", &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Pipeline")) - .collect(); + let pipe_diags = diags_matching(&diags, "Pipeline"); assert!(pipe_diags.is_empty()); } #[test] fn missing_space_before_pipe() { let diags = analyze("users|> select(users.id)", &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("preceded by a space")) - .collect(); + let pipe_diags = diags_matching(&diags, "preceded by a space"); assert_eq!(pipe_diags.len(), 1); assert_eq!(pipe_diags[0].severity, DiagnosticSeverity::Warning); assert_eq!(pipe_diags[0].line, 0); @@ -53,10 +54,7 @@ mod diagnostics_tests { #[test] fn missing_space_after_pipe() { let diags = analyze("users |>select(users.id)", &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("followed by a space")) - .collect(); + let pipe_diags = diags_matching(&diags, "followed by a space"); assert_eq!(pipe_diags.len(), 1); assert_eq!(pipe_diags[0].severity, DiagnosticSeverity::Warning); } @@ -64,50 +62,35 @@ mod diagnostics_tests { #[test] fn missing_space_both_sides() { let diags = analyze("users|>select(users.id)", &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Pipeline")) - .collect(); + let pipe_diags = diags_matching(&diags, "Pipeline"); assert_eq!(pipe_diags.len(), 2); } #[test] fn tab_before_pipe_is_ok() { let diags = analyze("users\t|> select(users.id)", &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("preceded")) - .collect(); + let pipe_diags = diags_matching(&diags, "preceded"); assert!(pipe_diags.is_empty()); } #[test] fn tab_after_pipe_is_ok() { let diags = analyze("users |>\tselect(users.id)", &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("followed")) - .collect(); + let pipe_diags = diags_matching(&diags, "followed"); assert!(pipe_diags.is_empty()); } #[test] fn pipe_at_end_of_line_no_after_warning() { let diags = analyze("users |>", &empty_scope()); - let after: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("followed")) - .collect(); + let after = diags_matching(&diags, "followed"); assert!(after.is_empty()); } #[test] fn pipe_at_start_of_line_no_before_warning() { let diags = analyze("|> select(users.id)", &empty_scope()); - let before: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("preceded")) - .collect(); + let before = diags_matching(&diags, "preceded"); assert!(before.is_empty()); } @@ -117,10 +100,7 @@ mod diagnostics_tests { "a |> filter(fn(r) => r.a.x > 1) |> select(a.id)", &empty_scope(), ); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Pipeline")) - .collect(); + let pipe_diags = diags_matching(&diags, "Pipeline"); assert!(pipe_diags.is_empty()); } @@ -128,10 +108,7 @@ mod diagnostics_tests { fn multiline_pipe_spacing() { let source = "users\n|> filter(fn(r) => r.users.age > 18)\n|> select(users.name)"; let diags = analyze(source, &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Pipeline")) - .collect(); + let pipe_diags = diags_matching(&diags, "Pipeline"); assert!(pipe_diags.is_empty()); } @@ -139,20 +116,14 @@ mod diagnostics_tests { #[test] fn known_function_no_diagnostic() { let diags = analyze("users |> select(users.id)", &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown")) - .collect(); + let unknown = diags_matching(&diags, "Unknown"); assert!(unknown.is_empty()); } #[test] fn unknown_function_reported() { let diags = analyze("users |> foobar(users.id)", &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown")) - .collect(); + let unknown = diags_matching(&diags, "Unknown"); assert_eq!(unknown.len(), 1); assert!(unknown[0].message.contains("foobar")); assert_eq!(unknown[0].severity, DiagnosticSeverity::Info); @@ -163,10 +134,7 @@ mod diagnostics_tests { let mut scope = ScopeMap::new(); scope.add_binding("my_func".to_string(), 0, 0); let diags = analyze("x |> my_func(x.id)", &scope); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown")) - .collect(); + let unknown = diags_matching(&diags, "Unknown"); assert!(unknown.is_empty()); } @@ -174,10 +142,7 @@ mod diagnostics_tests { fn keyword_not_reported_as_unknown() { // "fn" after pipe should not be flagged let diags = analyze("users |> filter(fn(r) => r.users.id > 0)", &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown function: `fn`")) - .collect(); + let unknown = diags_matching(&diags, "Unknown function: `fn`"); assert!(unknown.is_empty()); } @@ -246,10 +211,7 @@ mod diagnostics_tests { for func in known { let source = format!("x |> {func}(x.id)"); let diags = analyze(&source, &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown")) - .collect(); + let unknown = diags_matching(&diags, "Unknown"); assert!( unknown.is_empty(), "Function `{func}` should be recognized but got: {:?}", @@ -261,10 +223,7 @@ mod diagnostics_tests { #[test] fn case_insensitive_function_check() { let diags = analyze("x |> SELECT(x.id)", &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown")) - .collect(); + let unknown = diags_matching(&diags, "Unknown"); assert!(unknown.is_empty()); } @@ -275,10 +234,7 @@ mod diagnostics_tests { "users |> filter(fn(r) => r.users.name = 'blah(')", &empty_scope(), ); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown function: `blah`")) - .collect(); + let unknown = diags_matching(&diags, "Unknown function: `blah`"); assert!(unknown.is_empty()); } @@ -288,20 +244,14 @@ mod diagnostics_tests { r"users |> filter(fn(r) => r.users.name = 'it\'s(test')", &empty_scope(), ); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown function: `s`")) - .collect(); + let unknown = diags_matching(&diags, "Unknown function: `s`"); assert!(unknown.is_empty()); } #[test] fn identifier_not_followed_by_paren_not_reported() { let diags = analyze("users |> select(users.foobar)", &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("Unknown")) - .collect(); + let unknown = diags_matching(&diags, "Unknown"); assert!(unknown.is_empty()); } @@ -339,10 +289,7 @@ mod diagnostics_tests { for kw in keywords { let source = format!("x |> filter(fn(r) => {kw}(r.x.y))"); let diags = analyze(&source, &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains(&format!("Unknown function: `{kw}`"))) - .collect(); + let unknown = diags_matching(&diags, &format!("Unknown function: `{kw}`")); assert!(unknown.is_empty(), "Keyword `{kw}` flagged as unknown"); } } @@ -350,20 +297,14 @@ mod diagnostics_tests { #[test] fn function_with_whitespace_before_paren() { let diags = analyze("x |> foobar (x.id)", &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("foobar")) - .collect(); + let unknown = diags_matching(&diags, "foobar"); assert_eq!(unknown.len(), 1); } #[test] fn underscore_identifier_as_function() { let diags = analyze("x |> _my_func(x.id)", &empty_scope()); - let unknown: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("_my_func")) - .collect(); + let unknown = diags_matching(&diags, "_my_func"); assert_eq!(unknown.len(), 1); } @@ -395,10 +336,7 @@ mod diagnostics_tests { fn diagnostics_report_correct_line_numbers() { let source = "users |> select(users.id)\norders|>filter(fn(r) => r.orders.x > 0)"; let diags = analyze(source, &empty_scope()); - let pipe_diags: Vec<_> = diags - .iter() - .filter(|d| d.message.contains("preceded")) - .collect(); + let pipe_diags = diags_matching(&diags, "preceded"); assert_eq!(pipe_diags.len(), 1); assert_eq!(pipe_diags[0].line, 1); } diff --git a/Lql/lql-lsp-rust/crates/lql-lsp/Cargo.toml b/Lql/lql-lsp-rust/crates/lql-lsp/Cargo.toml index 3c51f494..64230ab7 100644 --- a/Lql/lql-lsp-rust/crates/lql-lsp/Cargo.toml +++ b/Lql/lql-lsp-rust/crates/lql-lsp/Cargo.toml @@ -24,4 +24,5 @@ tracing = "0.1" reqwest = { version = "0.12", features = ["json"] } [dev-dependencies] +futures = "0.3" testcontainers-modules = { version = "0.15", features = ["postgres", "blocking"] } diff --git a/Lql/lql-lsp-rust/crates/lql-lsp/src/ai.rs b/Lql/lql-lsp-rust/crates/lql-lsp/src/ai.rs index 7a3563f2..750eb12b 100644 --- a/Lql/lql-lsp-rust/crates/lql-lsp/src/ai.rs +++ b/Lql/lql-lsp-rust/crates/lql-lsp/src/ai.rs @@ -354,6 +354,19 @@ impl AiCompletionProvider for SlowAiProvider { mod tests { use super::*; + fn empty_context() -> AiCompletionContext { + AiCompletionContext { + document_text: String::new(), + line: 0, + column: 0, + line_prefix: String::new(), + word_prefix: String::new(), + file_uri: String::new(), + available_tables: vec![], + schema_description: String::new(), + } + } + // ── AiConfig::from_json ──────────────────────────────────────────── #[test] fn parse_full_config() { @@ -575,16 +588,7 @@ mod tests { #[tokio::test] async fn slow_provider_returns_after_delay() { let provider = SlowAiProvider { delay_ms: 10 }; - let ctx = AiCompletionContext { - document_text: "".to_string(), - line: 0, - column: 0, - line_prefix: "".to_string(), - word_prefix: "".to_string(), - file_uri: "".to_string(), - available_tables: vec![], - schema_description: String::new(), - }; + let ctx = empty_context(); let items = provider.complete(&ctx).await; assert_eq!(items.len(), 1); assert_eq!(items[0].label, "ai_slow_result"); @@ -668,16 +672,7 @@ mod tests { #[tokio::test] async fn test_provider_items_are_snippet_kind() { let provider = TestAiProvider; - let ctx = AiCompletionContext { - document_text: "".to_string(), - line: 0, - column: 0, - line_prefix: "".to_string(), - word_prefix: "".to_string(), - file_uri: "".to_string(), - available_tables: vec![], - schema_description: String::new(), - }; + let ctx = empty_context(); let items = provider.complete(&ctx).await; for item in &items { assert_eq!(item.kind, CompletionKind::Snippet); @@ -759,16 +754,7 @@ mod tests { enabled: true, }; let provider = OllamaProvider::new(&config, "ref".to_string()); - let ctx = AiCompletionContext { - document_text: String::new(), - line: 0, - column: 0, - line_prefix: String::new(), - word_prefix: String::new(), - file_uri: String::new(), - available_tables: vec![], - schema_description: String::new(), - }; + let ctx = empty_context(); let items = provider.complete(&ctx).await; assert!(items.is_empty(), "non-JSON body must yield empty"); } @@ -809,16 +795,7 @@ mod tests { enabled: true, }; let provider = OllamaProvider::new(&config, "ref".to_string()); - let ctx = AiCompletionContext { - document_text: String::new(), - line: 0, - column: 0, - line_prefix: String::new(), - word_prefix: String::new(), - file_uri: String::new(), - available_tables: vec![], - schema_description: String::new(), - }; + let ctx = empty_context(); let items = provider.complete(&ctx).await; assert!(items.is_empty()); } @@ -861,16 +838,7 @@ mod tests { enabled: true, }; let provider = OllamaProvider::new(&config, "ref".to_string()); - let ctx = AiCompletionContext { - document_text: String::new(), - line: 0, - column: 0, - line_prefix: String::new(), - word_prefix: String::new(), - file_uri: String::new(), - available_tables: vec![], - schema_description: String::new(), - }; + let ctx = empty_context(); let items = provider.complete(&ctx).await; assert!(items.iter().any(|i| i.label == "foo"), "should parse foo"); } diff --git a/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs b/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs index 15d97d80..d182f1a1 100644 --- a/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs +++ b/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs @@ -138,6 +138,12 @@ impl LqlBackend { (Some(word), None) } + /// Returns a clone of the current source text for `uri`, or `None` if the + /// document is not open. Centralizes the documents-map lock and lookup. + fn document_source(&self, uri: &Url) -> Option { + self.documents.lock().unwrap().get(uri).cloned() + } + fn compute_completion_context(source: &str, position: Position) -> CompletionContext { let line = source.lines().nth(position.line as usize).unwrap_or(""); let col = (position.character as usize).min(line.len()); @@ -340,12 +346,9 @@ impl LanguageServer for LqlBackend { let uri = ¶ms.text_document_position.text_document.uri; let position = params.text_document_position.position; - let source = { - let docs = self.documents.lock().unwrap(); - match docs.get(uri) { - Some(s) => s.clone(), - None => return Ok(None), - } + let source = match self.document_source(uri) { + Some(s) => s, + None => return Ok(None), }; let scope = build_scope(&source); @@ -449,12 +452,9 @@ impl LanguageServer for LqlBackend { let uri = ¶ms.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; - let source = { - let docs = self.documents.lock().unwrap(); - match docs.get(uri) { - Some(s) => s.clone(), - None => return Ok(None), - } + let source = match self.document_source(uri) { + Some(s) => s, + None => return Ok(None), }; let (word, qualified) = Self::get_qualified_at_position(&source, position); @@ -489,12 +489,9 @@ impl LanguageServer for LqlBackend { ) -> Result> { let uri = ¶ms.text_document.uri; - let source = { - let docs = self.documents.lock().unwrap(); - match docs.get(uri) { - Some(s) => s.clone(), - None => return Ok(None), - } + let source = match self.document_source(uri) { + Some(s) => s, + None => return Ok(None), }; let symbols = extract_symbols(&source); @@ -530,12 +527,9 @@ impl LanguageServer for LqlBackend { async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { let uri = ¶ms.text_document.uri; - let source = { - let docs = self.documents.lock().unwrap(); - match docs.get(uri) { - Some(s) => s.clone(), - None => return Ok(None), - } + let source = match self.document_source(uri) { + Some(s) => s, + None => return Ok(None), }; let formatted = format_lql(&source); @@ -652,6 +646,19 @@ async fn main() { mod tests { use super::*; + async fn open_doc(backend: &LqlBackend, uri: &Url, text: &str) { + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "lql".to_string(), + version: 1, + text: text.to_string(), + }, + }) + .await; + } + // ── format_lql ── #[test] @@ -1161,17 +1168,7 @@ mod tests { let uri = Url::parse("file:///test.lql").unwrap(); // Open a document - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "users |> ".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "users |> ").await; // Request completions let params = CompletionParams { @@ -1197,17 +1194,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "select".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "select").await; let params = HoverParams { text_document_position_params: TextDocumentPositionParams { @@ -1229,17 +1216,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "let x = users\nlet y = orders".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "let x = users\nlet y = orders").await; let params = DocumentSymbolParams { text_document: TextDocumentIdentifier { uri: uri.clone() }, @@ -1260,17 +1237,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "users".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "users").await; service .inner() @@ -1305,17 +1272,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "users".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "users").await; service .inner() @@ -1341,17 +1298,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "users\n|> select(\nusers.id\n)".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "users\n|> select(\nusers.id\n)").await; let params = DocumentFormattingParams { text_document: TextDocumentIdentifier { uri }, @@ -1374,17 +1321,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "users\n".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "users\n").await; let params = DocumentFormattingParams { text_document: TextDocumentIdentifier { uri }, @@ -1404,17 +1341,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "zzz_unknown".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "zzz_unknown").await; let params = HoverParams { text_document_position_params: TextDocumentPositionParams { @@ -1432,17 +1359,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "users |> ".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "users |> ").await; let params = CompletionParams { text_document_position: TextDocumentPositionParams { @@ -1496,17 +1413,7 @@ mod tests { let (service, _socket) = LspService::new(LqlBackend::new); let uri = Url::parse("file:///test.lql").unwrap(); - service - .inner() - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lql".to_string(), - version: 1, - text: "let my_query = users\nmy_query |> ".to_string(), - }, - }) - .await; + open_doc(service.inner(), &uri, "let my_query = users\nmy_query |> ").await; let params = CompletionParams { text_document_position: TextDocumentPositionParams { @@ -1955,4 +1862,163 @@ mod tests { // saturating_add(u64::MAX, 5000) saturates at u64::MAX assert!(msg.contains(&format!("{}ms delay", u64::MAX))); } + + // ── initialized() + AI completion merge ─────────────────────────── + + fn sqlite_schema_db(name: &str) -> String { + let path = std::env::temp_dir().join(format!("{name}_{}.db", std::process::id())); + let path_str = path.to_str().expect("utf-8 temp path").to_string(); + let _ = std::fs::remove_file(&path); + let conn = rusqlite::Connection::open(&path_str).expect("open sqlite"); + conn.execute_batch("CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);") + .expect("create table"); + path_str + } + + /// `initialized()` logs through the client; drain its socket so those sends complete. + fn drain_client_messages(socket: tower_lsp::ClientSocket) { + use futures::StreamExt; + tokio::spawn(async move { + let (mut requests, _responses) = socket.split(); + while requests.next().await.is_some() {} + }); + } + + async fn initialize_with(backend: &LqlBackend, options: serde_json::Value) { + backend + .initialize(InitializeParams { + initialization_options: Some(options), + ..Default::default() + }) + .await + .expect("initialize"); + backend.initialized(InitializedParams {}).await; + } + + async fn completion_labels(backend: &LqlBackend, uri: &Url, position: Position) -> Vec { + let params = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: None, + }; + match backend.completion(params).await.expect("completion") { + Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(), + other => panic!("expected completion array, got {other:?}"), + } + } + + #[tokio::test] + async fn test_initialized_loads_sqlite_schema_and_activates_ai_provider() { + let db = sqlite_schema_db("lql_initialized_schema"); + let (service, socket) = LspService::new(LqlBackend::new); + drain_client_messages(socket); + initialize_with( + service.inner(), + serde_json::json!({ + "connectionString": db, + "aiProvider": { "provider": "test", "endpoint": "http://unused" } + }), + ) + .await; + + let schema = service + .inner() + .schema + .read() + .await + .clone() + .expect("schema loaded"); + assert_eq!(schema.table_names(), vec!["users"]); + assert!(service.inner().ai_provider.read().await.is_some()); + let _ = std::fs::remove_file(&db); + } + + #[tokio::test] + async fn test_initialized_schema_fetch_failure_leaves_schema_empty() { + let (service, socket) = LspService::new(LqlBackend::new); + drain_client_messages(socket); + initialize_with( + service.inner(), + serde_json::json!({ + "connectionString": "host=127.0.0.1 port=1 dbname=none user=none connect_timeout=2", + "aiProvider": { "provider": "test", "endpoint": "http://unused", "enabled": false } + }), + ) + .await; + + assert!(service.inner().schema.read().await.is_none()); + assert!(service.inner().ai_provider.read().await.is_none()); + } + + #[tokio::test] + async fn test_completion_merges_ai_items_with_schema_context() { + use lql_analyzer::{ColumnInfo, SchemaCache, TableInfo}; + let (service, _socket) = LspService::new(LqlBackend::new); + service + .inner() + .set_ai_provider(Arc::new(ai::TestAiProvider)) + .await; + *service.inner().schema.write().await = Some(SchemaCache::from_tables(vec![TableInfo { + name: "users".to_string(), + schema: "public".to_string(), + columns: vec![ + ColumnInfo { + name: "id".to_string(), + sql_type: "uuid".to_string(), + is_nullable: false, + is_primary_key: true, + }, + ColumnInfo { + name: "name".to_string(), + sql_type: "text".to_string(), + is_nullable: true, + is_primary_key: false, + }, + ], + }])); + let uri = Url::parse("file:///ai_merge.lql").unwrap(); + open_doc(service.inner(), &uri, "users |> ").await; + + let labels = completion_labels(service.inner(), &uri, Position::new(0, 9)).await; + + assert!( + labels.contains(&"ai_suggest_filter".to_string()), + "{labels:?}" + ); + assert!( + labels.contains(&"ai_suggest_join".to_string()), + "{labels:?}" + ); + assert!(labels.contains(&"select".to_string()), "{labels:?}"); + } + + #[tokio::test] + async fn test_completion_drops_ai_items_after_timeout() { + let db = sqlite_schema_db("lql_ai_timeout"); + let (service, socket) = LspService::new(LqlBackend::new); + drain_client_messages(socket); + initialize_with( + service.inner(), + serde_json::json!({ + "connectionString": db, + "aiProvider": { "provider": "test_slow", "endpoint": "http://unused", "timeoutMs": 20 } + }), + ) + .await; + let uri = Url::parse("file:///ai_timeout.lql").unwrap(); + open_doc(service.inner(), &uri, "users |> ").await; + + let labels = completion_labels(service.inner(), &uri, Position::new(0, 9)).await; + + assert!( + !labels.contains(&"ai_slow_result".to_string()), + "{labels:?}" + ); + assert!(labels.contains(&"select".to_string()), "{labels:?}"); + let _ = std::fs::remove_file(&db); + } } diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.interp b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.interp index deb08658..b839a5f9 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.interp +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.interp @@ -1,11 +1,11 @@ token literal names: null +',' +'(' +')' 'let' '=' '|>' -'(' -')' -',' 'fn' '=>' '.' @@ -121,6 +121,8 @@ ASTERISK rule names: program statement +withStmt +cteDef letStmt pipeExpr expr @@ -154,4 +156,4 @@ comparisonOp atn: -[3, 24715, 42794, 33075, 47597, 16764, 15335, 30598, 22884, 3, 59, 399, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 4, 18, 9, 18, 4, 19, 9, 19, 4, 20, 9, 20, 4, 21, 9, 21, 4, 22, 9, 22, 4, 23, 9, 23, 4, 24, 9, 24, 4, 25, 9, 25, 4, 26, 9, 26, 4, 27, 9, 27, 4, 28, 9, 28, 4, 29, 9, 29, 4, 30, 9, 30, 4, 31, 9, 31, 4, 32, 9, 32, 4, 33, 9, 33, 3, 2, 7, 2, 68, 10, 2, 12, 2, 14, 2, 71, 11, 2, 3, 2, 3, 2, 3, 3, 3, 3, 5, 3, 77, 10, 3, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 5, 3, 5, 3, 5, 7, 5, 87, 10, 5, 12, 5, 14, 5, 90, 11, 5, 3, 6, 3, 6, 3, 6, 5, 6, 95, 10, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 5, 6, 106, 10, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 5, 6, 122, 10, 6, 3, 7, 5, 7, 125, 10, 7, 3, 7, 5, 7, 128, 10, 7, 3, 8, 3, 8, 3, 8, 3, 8, 3, 9, 3, 9, 3, 9, 3, 9, 3, 10, 3, 10, 3, 10, 7, 10, 141, 10, 10, 12, 10, 14, 10, 144, 11, 10, 3, 11, 3, 11, 3, 11, 3, 11, 5, 11, 150, 10, 11, 3, 11, 5, 11, 153, 10, 11, 3, 12, 3, 12, 3, 12, 3, 12, 3, 12, 7, 12, 160, 10, 12, 12, 12, 14, 12, 163, 11, 12, 3, 12, 3, 12, 3, 12, 3, 12, 3, 13, 3, 13, 3, 13, 6, 13, 172, 10, 13, 13, 13, 14, 13, 173, 3, 14, 3, 14, 3, 14, 7, 14, 179, 10, 14, 12, 14, 14, 14, 182, 11, 14, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 3, 15, 5, 15, 197, 10, 15, 3, 16, 3, 16, 3, 16, 3, 16, 5, 16, 203, 10, 16, 3, 16, 3, 16, 5, 16, 207, 10, 16, 3, 17, 3, 17, 3, 17, 7, 17, 212, 10, 17, 12, 17, 14, 17, 215, 11, 17, 3, 18, 3, 18, 3, 18, 7, 18, 220, 10, 18, 12, 18, 14, 18, 223, 11, 18, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 3, 19, 5, 19, 237, 10, 19, 3, 20, 3, 20, 3, 20, 5, 20, 242, 10, 20, 3, 20, 5, 20, 245, 10, 20, 3, 20, 3, 20, 3, 20, 3, 20, 3, 20, 3, 20, 5, 20, 253, 10, 20, 3, 21, 3, 21, 3, 21, 3, 21, 5, 21, 259, 10, 21, 3, 22, 3, 22, 3, 22, 7, 22, 264, 10, 22, 12, 22, 14, 22, 267, 11, 22, 3, 23, 3, 23, 3, 23, 7, 23, 272, 10, 23, 12, 23, 14, 23, 275, 11, 23, 3, 24, 3, 24, 3, 24, 3, 24, 3, 24, 5, 24, 282, 10, 24, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 5, 25, 296, 10, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 5, 25, 306, 10, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 5, 25, 316, 10, 25, 3, 25, 3, 25, 5, 25, 320, 10, 25, 3, 25, 3, 25, 5, 25, 324, 10, 25, 3, 25, 3, 25, 5, 25, 328, 10, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 3, 25, 5, 25, 337, 10, 25, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 27, 3, 27, 3, 27, 5, 27, 347, 10, 27, 3, 27, 3, 27, 5, 27, 351, 10, 27, 3, 27, 3, 27, 3, 28, 3, 28, 3, 28, 5, 28, 358, 10, 28, 3, 28, 3, 28, 3, 28, 3, 28, 5, 28, 364, 10, 28, 3, 28, 3, 28, 3, 29, 3, 29, 6, 29, 370, 10, 29, 13, 29, 14, 29, 371, 3, 29, 3, 29, 5, 29, 376, 10, 29, 3, 29, 3, 29, 3, 30, 3, 30, 3, 30, 3, 30, 3, 30, 3, 31, 3, 31, 3, 31, 3, 31, 3, 31, 3, 31, 3, 31, 3, 31, 5, 31, 393, 10, 31, 3, 32, 3, 32, 3, 33, 3, 33, 3, 33, 2, 2, 34, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 2, 7, 3, 2, 12, 14, 4, 2, 15, 16, 59, 59, 4, 2, 50, 50, 53, 53, 3, 2, 23, 24, 5, 2, 4, 4, 17, 22, 51, 51, 2, 468, 2, 69, 3, 2, 2, 2, 4, 76, 3, 2, 2, 2, 6, 78, 3, 2, 2, 2, 8, 83, 3, 2, 2, 2, 10, 121, 3, 2, 2, 2, 12, 124, 3, 2, 2, 2, 14, 129, 3, 2, 2, 2, 16, 133, 3, 2, 2, 2, 18, 137, 3, 2, 2, 2, 20, 149, 3, 2, 2, 2, 22, 154, 3, 2, 2, 2, 24, 168, 3, 2, 2, 2, 26, 175, 3, 2, 2, 2, 28, 196, 3, 2, 2, 2, 30, 202, 3, 2, 2, 2, 32, 208, 3, 2, 2, 2, 34, 216, 3, 2, 2, 2, 36, 236, 3, 2, 2, 2, 38, 238, 3, 2, 2, 2, 40, 254, 3, 2, 2, 2, 42, 260, 3, 2, 2, 2, 44, 268, 3, 2, 2, 2, 46, 281, 3, 2, 2, 2, 48, 336, 3, 2, 2, 2, 50, 338, 3, 2, 2, 2, 52, 346, 3, 2, 2, 2, 54, 357, 3, 2, 2, 2, 56, 367, 3, 2, 2, 2, 58, 379, 3, 2, 2, 2, 60, 392, 3, 2, 2, 2, 62, 394, 3, 2, 2, 2, 64, 396, 3, 2, 2, 2, 66, 68, 5, 4, 3, 2, 67, 66, 3, 2, 2, 2, 68, 71, 3, 2, 2, 2, 69, 67, 3, 2, 2, 2, 69, 70, 3, 2, 2, 2, 70, 72, 3, 2, 2, 2, 71, 69, 3, 2, 2, 2, 72, 73, 7, 2, 2, 3, 73, 3, 3, 2, 2, 2, 74, 77, 5, 6, 4, 2, 75, 77, 5, 8, 5, 2, 76, 74, 3, 2, 2, 2, 76, 75, 3, 2, 2, 2, 77, 5, 3, 2, 2, 2, 78, 79, 7, 3, 2, 2, 79, 80, 7, 53, 2, 2, 80, 81, 7, 4, 2, 2, 81, 82, 5, 8, 5, 2, 82, 7, 3, 2, 2, 2, 83, 88, 5, 10, 6, 2, 84, 85, 7, 5, 2, 2, 85, 87, 5, 10, 6, 2, 86, 84, 3, 2, 2, 2, 87, 90, 3, 2, 2, 2, 88, 86, 3, 2, 2, 2, 88, 89, 3, 2, 2, 2, 89, 9, 3, 2, 2, 2, 90, 88, 3, 2, 2, 2, 91, 92, 7, 53, 2, 2, 92, 94, 7, 6, 2, 2, 93, 95, 5, 26, 14, 2, 94, 93, 3, 2, 2, 2, 94, 95, 3, 2, 2, 2, 95, 96, 3, 2, 2, 2, 96, 97, 7, 7, 2, 2, 97, 98, 7, 40, 2, 2, 98, 99, 7, 6, 2, 2, 99, 100, 5, 12, 7, 2, 100, 101, 7, 7, 2, 2, 101, 122, 3, 2, 2, 2, 102, 103, 7, 53, 2, 2, 103, 105, 7, 6, 2, 2, 104, 106, 5, 26, 14, 2, 105, 104, 3, 2, 2, 2, 105, 106, 3, 2, 2, 2, 106, 107, 3, 2, 2, 2, 107, 122, 7, 7, 2, 2, 108, 122, 7, 53, 2, 2, 109, 110, 7, 6, 2, 2, 110, 111, 5, 8, 5, 2, 111, 112, 7, 7, 2, 2, 112, 122, 3, 2, 2, 2, 113, 122, 5, 24, 13, 2, 114, 122, 5, 22, 12, 2, 115, 122, 5, 56, 29, 2, 116, 122, 7, 54, 2, 2, 117, 122, 7, 55, 2, 2, 118, 122, 7, 59, 2, 2, 119, 122, 7, 56, 2, 2, 120, 122, 7, 52, 2, 2, 121, 91, 3, 2, 2, 2, 121, 102, 3, 2, 2, 2, 121, 108, 3, 2, 2, 2, 121, 109, 3, 2, 2, 2, 121, 113, 3, 2, 2, 2, 121, 114, 3, 2, 2, 2, 121, 115, 3, 2, 2, 2, 121, 116, 3, 2, 2, 2, 121, 117, 3, 2, 2, 2, 121, 118, 3, 2, 2, 2, 121, 119, 3, 2, 2, 2, 121, 120, 3, 2, 2, 2, 122, 11, 3, 2, 2, 2, 123, 125, 5, 14, 8, 2, 124, 123, 3, 2, 2, 2, 124, 125, 3, 2, 2, 2, 125, 127, 3, 2, 2, 2, 126, 128, 5, 16, 9, 2, 127, 126, 3, 2, 2, 2, 127, 128, 3, 2, 2, 2, 128, 13, 3, 2, 2, 2, 129, 130, 7, 41, 2, 2, 130, 131, 7, 43, 2, 2, 131, 132, 5, 26, 14, 2, 132, 15, 3, 2, 2, 2, 133, 134, 7, 42, 2, 2, 134, 135, 7, 43, 2, 2, 135, 136, 5, 18, 10, 2, 136, 17, 3, 2, 2, 2, 137, 142, 5, 20, 11, 2, 138, 139, 7, 8, 2, 2, 139, 141, 5, 20, 11, 2, 140, 138, 3, 2, 2, 2, 141, 144, 3, 2, 2, 2, 142, 140, 3, 2, 2, 2, 142, 143, 3, 2, 2, 2, 143, 19, 3, 2, 2, 2, 144, 142, 3, 2, 2, 2, 145, 150, 5, 32, 17, 2, 146, 150, 5, 38, 20, 2, 147, 150, 5, 24, 13, 2, 148, 150, 7, 53, 2, 2, 149, 145, 3, 2, 2, 2, 149, 146, 3, 2, 2, 2, 149, 147, 3, 2, 2, 2, 149, 148, 3, 2, 2, 2, 150, 152, 3, 2, 2, 2, 151, 153, 5, 62, 32, 2, 152, 151, 3, 2, 2, 2, 152, 153, 3, 2, 2, 2, 153, 21, 3, 2, 2, 2, 154, 155, 7, 9, 2, 2, 155, 156, 7, 6, 2, 2, 156, 161, 7, 53, 2, 2, 157, 158, 7, 8, 2, 2, 158, 160, 7, 53, 2, 2, 159, 157, 3, 2, 2, 2, 160, 163, 3, 2, 2, 2, 161, 159, 3, 2, 2, 2, 161, 162, 3, 2, 2, 2, 162, 164, 3, 2, 2, 2, 163, 161, 3, 2, 2, 2, 164, 165, 7, 7, 2, 2, 165, 166, 7, 10, 2, 2, 166, 167, 5, 42, 22, 2, 167, 23, 3, 2, 2, 2, 168, 171, 7, 53, 2, 2, 169, 170, 7, 11, 2, 2, 170, 172, 7, 53, 2, 2, 171, 169, 3, 2, 2, 2, 172, 173, 3, 2, 2, 2, 173, 171, 3, 2, 2, 2, 173, 174, 3, 2, 2, 2, 174, 25, 3, 2, 2, 2, 175, 180, 5, 28, 15, 2, 176, 177, 7, 8, 2, 2, 177, 179, 5, 28, 15, 2, 178, 176, 3, 2, 2, 2, 179, 182, 3, 2, 2, 2, 180, 178, 3, 2, 2, 2, 180, 181, 3, 2, 2, 2, 181, 27, 3, 2, 2, 2, 182, 180, 3, 2, 2, 2, 183, 197, 5, 30, 16, 2, 184, 197, 5, 32, 17, 2, 185, 197, 5, 38, 20, 2, 186, 197, 5, 56, 29, 2, 187, 197, 5, 10, 6, 2, 188, 197, 5, 40, 21, 2, 189, 197, 5, 48, 25, 2, 190, 197, 5, 8, 5, 2, 191, 197, 5, 22, 12, 2, 192, 193, 7, 6, 2, 2, 193, 194, 5, 8, 5, 2, 194, 195, 7, 7, 2, 2, 195, 197, 3, 2, 2, 2, 196, 183, 3, 2, 2, 2, 196, 184, 3, 2, 2, 2, 196, 185, 3, 2, 2, 2, 196, 186, 3, 2, 2, 2, 196, 187, 3, 2, 2, 2, 196, 188, 3, 2, 2, 2, 196, 189, 3, 2, 2, 2, 196, 190, 3, 2, 2, 2, 196, 191, 3, 2, 2, 2, 196, 192, 3, 2, 2, 2, 197, 29, 3, 2, 2, 2, 198, 203, 5, 32, 17, 2, 199, 203, 5, 38, 20, 2, 200, 203, 5, 24, 13, 2, 201, 203, 7, 53, 2, 2, 202, 198, 3, 2, 2, 2, 202, 199, 3, 2, 2, 2, 202, 200, 3, 2, 2, 2, 202, 201, 3, 2, 2, 2, 203, 206, 3, 2, 2, 2, 204, 205, 7, 33, 2, 2, 205, 207, 7, 53, 2, 2, 206, 204, 3, 2, 2, 2, 206, 207, 3, 2, 2, 2, 207, 31, 3, 2, 2, 2, 208, 213, 5, 34, 18, 2, 209, 210, 9, 2, 2, 2, 210, 212, 5, 34, 18, 2, 211, 209, 3, 2, 2, 2, 212, 215, 3, 2, 2, 2, 213, 211, 3, 2, 2, 2, 213, 214, 3, 2, 2, 2, 214, 33, 3, 2, 2, 2, 215, 213, 3, 2, 2, 2, 216, 221, 5, 36, 19, 2, 217, 218, 9, 3, 2, 2, 218, 220, 5, 36, 19, 2, 219, 217, 3, 2, 2, 2, 220, 223, 3, 2, 2, 2, 221, 219, 3, 2, 2, 2, 221, 222, 3, 2, 2, 2, 222, 35, 3, 2, 2, 2, 223, 221, 3, 2, 2, 2, 224, 237, 5, 24, 13, 2, 225, 237, 7, 53, 2, 2, 226, 237, 7, 54, 2, 2, 227, 237, 7, 55, 2, 2, 228, 237, 7, 56, 2, 2, 229, 237, 5, 38, 20, 2, 230, 237, 5, 56, 29, 2, 231, 237, 7, 52, 2, 2, 232, 233, 7, 6, 2, 2, 233, 234, 5, 32, 17, 2, 234, 235, 7, 7, 2, 2, 235, 237, 3, 2, 2, 2, 236, 224, 3, 2, 2, 2, 236, 225, 3, 2, 2, 2, 236, 226, 3, 2, 2, 2, 236, 227, 3, 2, 2, 2, 236, 228, 3, 2, 2, 2, 236, 229, 3, 2, 2, 2, 236, 230, 3, 2, 2, 2, 236, 231, 3, 2, 2, 2, 236, 232, 3, 2, 2, 2, 237, 37, 3, 2, 2, 2, 238, 239, 7, 53, 2, 2, 239, 244, 7, 6, 2, 2, 240, 242, 7, 27, 2, 2, 241, 240, 3, 2, 2, 2, 241, 242, 3, 2, 2, 2, 242, 243, 3, 2, 2, 2, 243, 245, 5, 26, 14, 2, 244, 241, 3, 2, 2, 2, 244, 245, 3, 2, 2, 2, 245, 246, 3, 2, 2, 2, 246, 252, 7, 7, 2, 2, 247, 248, 7, 40, 2, 2, 248, 249, 7, 6, 2, 2, 249, 250, 5, 12, 7, 2, 250, 251, 7, 7, 2, 2, 251, 253, 3, 2, 2, 2, 252, 247, 3, 2, 2, 2, 252, 253, 3, 2, 2, 2, 253, 39, 3, 2, 2, 2, 254, 255, 9, 4, 2, 2, 255, 258, 7, 4, 2, 2, 256, 259, 5, 48, 25, 2, 257, 259, 5, 42, 22, 2, 258, 256, 3, 2, 2, 2, 258, 257, 3, 2, 2, 2, 259, 41, 3, 2, 2, 2, 260, 265, 5, 44, 23, 2, 261, 262, 7, 26, 2, 2, 262, 264, 5, 44, 23, 2, 263, 261, 3, 2, 2, 2, 264, 267, 3, 2, 2, 2, 265, 263, 3, 2, 2, 2, 265, 266, 3, 2, 2, 2, 266, 43, 3, 2, 2, 2, 267, 265, 3, 2, 2, 2, 268, 273, 5, 46, 24, 2, 269, 270, 7, 25, 2, 2, 270, 272, 5, 46, 24, 2, 271, 269, 3, 2, 2, 2, 272, 275, 3, 2, 2, 2, 273, 271, 3, 2, 2, 2, 273, 274, 3, 2, 2, 2, 274, 45, 3, 2, 2, 2, 275, 273, 3, 2, 2, 2, 276, 282, 5, 48, 25, 2, 277, 278, 7, 6, 2, 2, 278, 279, 5, 42, 22, 2, 279, 280, 7, 7, 2, 2, 280, 282, 3, 2, 2, 2, 281, 276, 3, 2, 2, 2, 281, 277, 3, 2, 2, 2, 282, 47, 3, 2, 2, 2, 283, 284, 5, 32, 17, 2, 284, 285, 5, 64, 33, 2, 285, 286, 5, 32, 17, 2, 286, 337, 3, 2, 2, 2, 287, 288, 5, 24, 13, 2, 288, 295, 5, 64, 33, 2, 289, 296, 5, 24, 13, 2, 290, 296, 7, 56, 2, 2, 291, 296, 7, 53, 2, 2, 292, 296, 7, 54, 2, 2, 293, 296, 7, 55, 2, 2, 294, 296, 7, 52, 2, 2, 295, 289, 3, 2, 2, 2, 295, 290, 3, 2, 2, 2, 295, 291, 3, 2, 2, 2, 295, 292, 3, 2, 2, 2, 295, 293, 3, 2, 2, 2, 295, 294, 3, 2, 2, 2, 296, 337, 3, 2, 2, 2, 297, 298, 7, 53, 2, 2, 298, 305, 5, 64, 33, 2, 299, 306, 5, 24, 13, 2, 300, 306, 7, 56, 2, 2, 301, 306, 7, 53, 2, 2, 302, 306, 7, 54, 2, 2, 303, 306, 7, 55, 2, 2, 304, 306, 7, 52, 2, 2, 305, 299, 3, 2, 2, 2, 305, 300, 3, 2, 2, 2, 305, 301, 3, 2, 2, 2, 305, 302, 3, 2, 2, 2, 305, 303, 3, 2, 2, 2, 305, 304, 3, 2, 2, 2, 306, 337, 3, 2, 2, 2, 307, 308, 7, 52, 2, 2, 308, 315, 5, 64, 33, 2, 309, 316, 5, 24, 13, 2, 310, 316, 7, 56, 2, 2, 311, 316, 7, 53, 2, 2, 312, 316, 7, 54, 2, 2, 313, 316, 7, 55, 2, 2, 314, 316, 7, 52, 2, 2, 315, 309, 3, 2, 2, 2, 315, 310, 3, 2, 2, 2, 315, 311, 3, 2, 2, 2, 315, 312, 3, 2, 2, 2, 315, 313, 3, 2, 2, 2, 315, 314, 3, 2, 2, 2, 316, 337, 3, 2, 2, 2, 317, 319, 5, 24, 13, 2, 318, 320, 5, 62, 32, 2, 319, 318, 3, 2, 2, 2, 319, 320, 3, 2, 2, 2, 320, 337, 3, 2, 2, 2, 321, 323, 7, 53, 2, 2, 322, 324, 5, 62, 32, 2, 323, 322, 3, 2, 2, 2, 323, 324, 3, 2, 2, 2, 324, 337, 3, 2, 2, 2, 325, 327, 7, 52, 2, 2, 326, 328, 5, 62, 32, 2, 327, 326, 3, 2, 2, 2, 327, 328, 3, 2, 2, 2, 328, 337, 3, 2, 2, 2, 329, 337, 7, 56, 2, 2, 330, 337, 7, 54, 2, 2, 331, 337, 7, 55, 2, 2, 332, 337, 5, 10, 6, 2, 333, 337, 5, 50, 26, 2, 334, 337, 5, 52, 27, 2, 335, 337, 5, 54, 28, 2, 336, 283, 3, 2, 2, 2, 336, 287, 3, 2, 2, 2, 336, 297, 3, 2, 2, 2, 336, 307, 3, 2, 2, 2, 336, 317, 3, 2, 2, 2, 336, 321, 3, 2, 2, 2, 336, 325, 3, 2, 2, 2, 336, 329, 3, 2, 2, 2, 336, 330, 3, 2, 2, 2, 336, 331, 3, 2, 2, 2, 336, 332, 3, 2, 2, 2, 336, 333, 3, 2, 2, 2, 336, 334, 3, 2, 2, 2, 336, 335, 3, 2, 2, 2, 337, 49, 3, 2, 2, 2, 338, 339, 7, 28, 2, 2, 339, 340, 7, 6, 2, 2, 340, 341, 5, 8, 5, 2, 341, 342, 7, 7, 2, 2, 342, 51, 3, 2, 2, 2, 343, 347, 5, 24, 13, 2, 344, 347, 7, 53, 2, 2, 345, 347, 7, 52, 2, 2, 346, 343, 3, 2, 2, 2, 346, 344, 3, 2, 2, 2, 346, 345, 3, 2, 2, 2, 347, 348, 3, 2, 2, 2, 348, 350, 7, 30, 2, 2, 349, 351, 7, 31, 2, 2, 350, 349, 3, 2, 2, 2, 350, 351, 3, 2, 2, 2, 351, 352, 3, 2, 2, 2, 352, 353, 7, 29, 2, 2, 353, 53, 3, 2, 2, 2, 354, 358, 5, 24, 13, 2, 355, 358, 7, 53, 2, 2, 356, 358, 7, 52, 2, 2, 357, 354, 3, 2, 2, 2, 357, 355, 3, 2, 2, 2, 357, 356, 3, 2, 2, 2, 358, 359, 3, 2, 2, 2, 359, 360, 7, 32, 2, 2, 360, 363, 7, 6, 2, 2, 361, 364, 5, 8, 5, 2, 362, 364, 5, 26, 14, 2, 363, 361, 3, 2, 2, 2, 363, 362, 3, 2, 2, 2, 364, 365, 3, 2, 2, 2, 365, 366, 7, 7, 2, 2, 366, 55, 3, 2, 2, 2, 367, 369, 7, 34, 2, 2, 368, 370, 5, 58, 30, 2, 369, 368, 3, 2, 2, 2, 370, 371, 3, 2, 2, 2, 371, 369, 3, 2, 2, 2, 371, 372, 3, 2, 2, 2, 372, 375, 3, 2, 2, 2, 373, 374, 7, 37, 2, 2, 374, 376, 5, 60, 31, 2, 375, 373, 3, 2, 2, 2, 375, 376, 3, 2, 2, 2, 376, 377, 3, 2, 2, 2, 377, 378, 7, 38, 2, 2, 378, 57, 3, 2, 2, 2, 379, 380, 7, 35, 2, 2, 380, 381, 5, 48, 25, 2, 381, 382, 7, 36, 2, 2, 382, 383, 5, 60, 31, 2, 383, 59, 3, 2, 2, 2, 384, 393, 5, 32, 17, 2, 385, 393, 5, 48, 25, 2, 386, 393, 5, 24, 13, 2, 387, 393, 7, 53, 2, 2, 388, 393, 7, 54, 2, 2, 389, 393, 7, 55, 2, 2, 390, 393, 7, 56, 2, 2, 391, 393, 7, 52, 2, 2, 392, 384, 3, 2, 2, 2, 392, 385, 3, 2, 2, 2, 392, 386, 3, 2, 2, 2, 392, 387, 3, 2, 2, 2, 392, 388, 3, 2, 2, 2, 392, 389, 3, 2, 2, 2, 392, 390, 3, 2, 2, 2, 392, 391, 3, 2, 2, 2, 393, 61, 3, 2, 2, 2, 394, 395, 9, 5, 2, 2, 395, 63, 3, 2, 2, 2, 396, 397, 9, 6, 2, 2, 397, 65, 3, 2, 2, 2, 43, 69, 76, 88, 94, 105, 121, 124, 127, 142, 149, 152, 161, 173, 180, 196, 202, 206, 213, 221, 236, 241, 244, 252, 258, 265, 273, 281, 295, 305, 315, 319, 323, 327, 336, 346, 350, 357, 363, 371, 375, 392] \ No newline at end of file +[3, 24715, 42794, 33075, 47597, 16764, 15335, 30598, 22884, 3, 59, 421, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 4, 18, 9, 18, 4, 19, 9, 19, 4, 20, 9, 20, 4, 21, 9, 21, 4, 22, 9, 22, 4, 23, 9, 23, 4, 24, 9, 24, 4, 25, 9, 25, 4, 26, 9, 26, 4, 27, 9, 27, 4, 28, 9, 28, 4, 29, 9, 29, 4, 30, 9, 30, 4, 31, 9, 31, 4, 32, 9, 32, 4, 33, 9, 33, 4, 34, 9, 34, 4, 35, 9, 35, 3, 2, 7, 2, 72, 10, 2, 12, 2, 14, 2, 75, 11, 2, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 5, 3, 82, 10, 3, 3, 4, 3, 4, 3, 4, 3, 4, 7, 4, 88, 10, 4, 12, 4, 14, 4, 91, 11, 4, 3, 4, 3, 4, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 5, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 3, 7, 3, 7, 3, 7, 7, 7, 109, 10, 7, 12, 7, 14, 7, 112, 11, 7, 3, 8, 3, 8, 3, 8, 5, 8, 117, 10, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 5, 8, 128, 10, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 3, 8, 5, 8, 144, 10, 8, 3, 9, 5, 9, 147, 10, 9, 3, 9, 5, 9, 150, 10, 9, 3, 10, 3, 10, 3, 10, 3, 10, 3, 11, 3, 11, 3, 11, 3, 11, 3, 12, 3, 12, 3, 12, 7, 12, 163, 10, 12, 12, 12, 14, 12, 166, 11, 12, 3, 13, 3, 13, 3, 13, 3, 13, 5, 13, 172, 10, 13, 3, 13, 5, 13, 175, 10, 13, 3, 14, 3, 14, 3, 14, 3, 14, 3, 14, 7, 14, 182, 10, 14, 12, 14, 14, 14, 185, 11, 14, 3, 14, 3, 14, 3, 14, 3, 14, 3, 15, 3, 15, 3, 15, 6, 15, 194, 10, 15, 13, 15, 14, 15, 195, 3, 16, 3, 16, 3, 16, 7, 16, 201, 10, 16, 12, 16, 14, 16, 204, 11, 16, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 3, 17, 5, 17, 219, 10, 17, 3, 18, 3, 18, 3, 18, 3, 18, 5, 18, 225, 10, 18, 3, 18, 3, 18, 5, 18, 229, 10, 18, 3, 19, 3, 19, 3, 19, 7, 19, 234, 10, 19, 12, 19, 14, 19, 237, 11, 19, 3, 20, 3, 20, 3, 20, 7, 20, 242, 10, 20, 12, 20, 14, 20, 245, 11, 20, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 3, 21, 5, 21, 259, 10, 21, 3, 22, 3, 22, 3, 22, 5, 22, 264, 10, 22, 3, 22, 5, 22, 267, 10, 22, 3, 22, 3, 22, 3, 22, 3, 22, 3, 22, 3, 22, 5, 22, 275, 10, 22, 3, 23, 3, 23, 3, 23, 3, 23, 5, 23, 281, 10, 23, 3, 24, 3, 24, 3, 24, 7, 24, 286, 10, 24, 12, 24, 14, 24, 289, 11, 24, 3, 25, 3, 25, 3, 25, 7, 25, 294, 10, 25, 12, 25, 14, 25, 297, 11, 25, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 5, 26, 304, 10, 26, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 5, 27, 318, 10, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 5, 27, 328, 10, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 5, 27, 338, 10, 27, 3, 27, 3, 27, 5, 27, 342, 10, 27, 3, 27, 3, 27, 5, 27, 346, 10, 27, 3, 27, 3, 27, 5, 27, 350, 10, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 5, 27, 359, 10, 27, 3, 28, 3, 28, 3, 28, 3, 28, 3, 28, 3, 29, 3, 29, 3, 29, 5, 29, 369, 10, 29, 3, 29, 3, 29, 5, 29, 373, 10, 29, 3, 29, 3, 29, 3, 30, 3, 30, 3, 30, 5, 30, 380, 10, 30, 3, 30, 3, 30, 3, 30, 3, 30, 5, 30, 386, 10, 30, 3, 30, 3, 30, 3, 31, 3, 31, 6, 31, 392, 10, 31, 13, 31, 14, 31, 393, 3, 31, 3, 31, 5, 31, 398, 10, 31, 3, 31, 3, 31, 3, 32, 3, 32, 3, 32, 3, 32, 3, 32, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 5, 33, 415, 10, 33, 3, 34, 3, 34, 3, 35, 3, 35, 3, 35, 2, 2, 36, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 2, 7, 3, 2, 12, 14, 4, 2, 15, 16, 59, 59, 4, 2, 50, 50, 53, 53, 3, 2, 23, 24, 5, 2, 7, 7, 17, 22, 51, 51, 2, 490, 2, 73, 3, 2, 2, 2, 4, 81, 3, 2, 2, 2, 6, 83, 3, 2, 2, 2, 8, 94, 3, 2, 2, 2, 10, 100, 3, 2, 2, 2, 12, 105, 3, 2, 2, 2, 14, 143, 3, 2, 2, 2, 16, 146, 3, 2, 2, 2, 18, 151, 3, 2, 2, 2, 20, 155, 3, 2, 2, 2, 22, 159, 3, 2, 2, 2, 24, 171, 3, 2, 2, 2, 26, 176, 3, 2, 2, 2, 28, 190, 3, 2, 2, 2, 30, 197, 3, 2, 2, 2, 32, 218, 3, 2, 2, 2, 34, 224, 3, 2, 2, 2, 36, 230, 3, 2, 2, 2, 38, 238, 3, 2, 2, 2, 40, 258, 3, 2, 2, 2, 42, 260, 3, 2, 2, 2, 44, 276, 3, 2, 2, 2, 46, 282, 3, 2, 2, 2, 48, 290, 3, 2, 2, 2, 50, 303, 3, 2, 2, 2, 52, 358, 3, 2, 2, 2, 54, 360, 3, 2, 2, 2, 56, 368, 3, 2, 2, 2, 58, 379, 3, 2, 2, 2, 60, 389, 3, 2, 2, 2, 62, 401, 3, 2, 2, 2, 64, 414, 3, 2, 2, 2, 66, 416, 3, 2, 2, 2, 68, 418, 3, 2, 2, 2, 70, 72, 5, 4, 3, 2, 71, 70, 3, 2, 2, 2, 72, 75, 3, 2, 2, 2, 73, 71, 3, 2, 2, 2, 73, 74, 3, 2, 2, 2, 74, 76, 3, 2, 2, 2, 75, 73, 3, 2, 2, 2, 76, 77, 7, 2, 2, 3, 77, 3, 3, 2, 2, 2, 78, 82, 5, 6, 4, 2, 79, 82, 5, 10, 6, 2, 80, 82, 5, 12, 7, 2, 81, 78, 3, 2, 2, 2, 81, 79, 3, 2, 2, 2, 81, 80, 3, 2, 2, 2, 82, 5, 3, 2, 2, 2, 83, 84, 7, 39, 2, 2, 84, 89, 5, 8, 5, 2, 85, 86, 7, 3, 2, 2, 86, 88, 5, 8, 5, 2, 87, 85, 3, 2, 2, 2, 88, 91, 3, 2, 2, 2, 89, 87, 3, 2, 2, 2, 89, 90, 3, 2, 2, 2, 90, 92, 3, 2, 2, 2, 91, 89, 3, 2, 2, 2, 92, 93, 5, 12, 7, 2, 93, 7, 3, 2, 2, 2, 94, 95, 7, 53, 2, 2, 95, 96, 7, 33, 2, 2, 96, 97, 7, 4, 2, 2, 97, 98, 5, 12, 7, 2, 98, 99, 7, 5, 2, 2, 99, 9, 3, 2, 2, 2, 100, 101, 7, 6, 2, 2, 101, 102, 7, 53, 2, 2, 102, 103, 7, 7, 2, 2, 103, 104, 5, 12, 7, 2, 104, 11, 3, 2, 2, 2, 105, 110, 5, 14, 8, 2, 106, 107, 7, 8, 2, 2, 107, 109, 5, 14, 8, 2, 108, 106, 3, 2, 2, 2, 109, 112, 3, 2, 2, 2, 110, 108, 3, 2, 2, 2, 110, 111, 3, 2, 2, 2, 111, 13, 3, 2, 2, 2, 112, 110, 3, 2, 2, 2, 113, 114, 7, 53, 2, 2, 114, 116, 7, 4, 2, 2, 115, 117, 5, 30, 16, 2, 116, 115, 3, 2, 2, 2, 116, 117, 3, 2, 2, 2, 117, 118, 3, 2, 2, 2, 118, 119, 7, 5, 2, 2, 119, 120, 7, 40, 2, 2, 120, 121, 7, 4, 2, 2, 121, 122, 5, 16, 9, 2, 122, 123, 7, 5, 2, 2, 123, 144, 3, 2, 2, 2, 124, 125, 7, 53, 2, 2, 125, 127, 7, 4, 2, 2, 126, 128, 5, 30, 16, 2, 127, 126, 3, 2, 2, 2, 127, 128, 3, 2, 2, 2, 128, 129, 3, 2, 2, 2, 129, 144, 7, 5, 2, 2, 130, 144, 7, 53, 2, 2, 131, 132, 7, 4, 2, 2, 132, 133, 5, 12, 7, 2, 133, 134, 7, 5, 2, 2, 134, 144, 3, 2, 2, 2, 135, 144, 5, 28, 15, 2, 136, 144, 5, 26, 14, 2, 137, 144, 5, 60, 31, 2, 138, 144, 7, 54, 2, 2, 139, 144, 7, 55, 2, 2, 140, 144, 7, 59, 2, 2, 141, 144, 7, 56, 2, 2, 142, 144, 7, 52, 2, 2, 143, 113, 3, 2, 2, 2, 143, 124, 3, 2, 2, 2, 143, 130, 3, 2, 2, 2, 143, 131, 3, 2, 2, 2, 143, 135, 3, 2, 2, 2, 143, 136, 3, 2, 2, 2, 143, 137, 3, 2, 2, 2, 143, 138, 3, 2, 2, 2, 143, 139, 3, 2, 2, 2, 143, 140, 3, 2, 2, 2, 143, 141, 3, 2, 2, 2, 143, 142, 3, 2, 2, 2, 144, 15, 3, 2, 2, 2, 145, 147, 5, 18, 10, 2, 146, 145, 3, 2, 2, 2, 146, 147, 3, 2, 2, 2, 147, 149, 3, 2, 2, 2, 148, 150, 5, 20, 11, 2, 149, 148, 3, 2, 2, 2, 149, 150, 3, 2, 2, 2, 150, 17, 3, 2, 2, 2, 151, 152, 7, 41, 2, 2, 152, 153, 7, 43, 2, 2, 153, 154, 5, 30, 16, 2, 154, 19, 3, 2, 2, 2, 155, 156, 7, 42, 2, 2, 156, 157, 7, 43, 2, 2, 157, 158, 5, 22, 12, 2, 158, 21, 3, 2, 2, 2, 159, 164, 5, 24, 13, 2, 160, 161, 7, 3, 2, 2, 161, 163, 5, 24, 13, 2, 162, 160, 3, 2, 2, 2, 163, 166, 3, 2, 2, 2, 164, 162, 3, 2, 2, 2, 164, 165, 3, 2, 2, 2, 165, 23, 3, 2, 2, 2, 166, 164, 3, 2, 2, 2, 167, 172, 5, 36, 19, 2, 168, 172, 5, 42, 22, 2, 169, 172, 5, 28, 15, 2, 170, 172, 7, 53, 2, 2, 171, 167, 3, 2, 2, 2, 171, 168, 3, 2, 2, 2, 171, 169, 3, 2, 2, 2, 171, 170, 3, 2, 2, 2, 172, 174, 3, 2, 2, 2, 173, 175, 5, 66, 34, 2, 174, 173, 3, 2, 2, 2, 174, 175, 3, 2, 2, 2, 175, 25, 3, 2, 2, 2, 176, 177, 7, 9, 2, 2, 177, 178, 7, 4, 2, 2, 178, 183, 7, 53, 2, 2, 179, 180, 7, 3, 2, 2, 180, 182, 7, 53, 2, 2, 181, 179, 3, 2, 2, 2, 182, 185, 3, 2, 2, 2, 183, 181, 3, 2, 2, 2, 183, 184, 3, 2, 2, 2, 184, 186, 3, 2, 2, 2, 185, 183, 3, 2, 2, 2, 186, 187, 7, 5, 2, 2, 187, 188, 7, 10, 2, 2, 188, 189, 5, 46, 24, 2, 189, 27, 3, 2, 2, 2, 190, 193, 7, 53, 2, 2, 191, 192, 7, 11, 2, 2, 192, 194, 7, 53, 2, 2, 193, 191, 3, 2, 2, 2, 194, 195, 3, 2, 2, 2, 195, 193, 3, 2, 2, 2, 195, 196, 3, 2, 2, 2, 196, 29, 3, 2, 2, 2, 197, 202, 5, 32, 17, 2, 198, 199, 7, 3, 2, 2, 199, 201, 5, 32, 17, 2, 200, 198, 3, 2, 2, 2, 201, 204, 3, 2, 2, 2, 202, 200, 3, 2, 2, 2, 202, 203, 3, 2, 2, 2, 203, 31, 3, 2, 2, 2, 204, 202, 3, 2, 2, 2, 205, 219, 5, 34, 18, 2, 206, 219, 5, 36, 19, 2, 207, 219, 5, 42, 22, 2, 208, 219, 5, 60, 31, 2, 209, 219, 5, 14, 8, 2, 210, 219, 5, 44, 23, 2, 211, 219, 5, 52, 27, 2, 212, 219, 5, 12, 7, 2, 213, 219, 5, 26, 14, 2, 214, 215, 7, 4, 2, 2, 215, 216, 5, 12, 7, 2, 216, 217, 7, 5, 2, 2, 217, 219, 3, 2, 2, 2, 218, 205, 3, 2, 2, 2, 218, 206, 3, 2, 2, 2, 218, 207, 3, 2, 2, 2, 218, 208, 3, 2, 2, 2, 218, 209, 3, 2, 2, 2, 218, 210, 3, 2, 2, 2, 218, 211, 3, 2, 2, 2, 218, 212, 3, 2, 2, 2, 218, 213, 3, 2, 2, 2, 218, 214, 3, 2, 2, 2, 219, 33, 3, 2, 2, 2, 220, 225, 5, 36, 19, 2, 221, 225, 5, 42, 22, 2, 222, 225, 5, 28, 15, 2, 223, 225, 7, 53, 2, 2, 224, 220, 3, 2, 2, 2, 224, 221, 3, 2, 2, 2, 224, 222, 3, 2, 2, 2, 224, 223, 3, 2, 2, 2, 225, 228, 3, 2, 2, 2, 226, 227, 7, 33, 2, 2, 227, 229, 7, 53, 2, 2, 228, 226, 3, 2, 2, 2, 228, 229, 3, 2, 2, 2, 229, 35, 3, 2, 2, 2, 230, 235, 5, 38, 20, 2, 231, 232, 9, 2, 2, 2, 232, 234, 5, 38, 20, 2, 233, 231, 3, 2, 2, 2, 234, 237, 3, 2, 2, 2, 235, 233, 3, 2, 2, 2, 235, 236, 3, 2, 2, 2, 236, 37, 3, 2, 2, 2, 237, 235, 3, 2, 2, 2, 238, 243, 5, 40, 21, 2, 239, 240, 9, 3, 2, 2, 240, 242, 5, 40, 21, 2, 241, 239, 3, 2, 2, 2, 242, 245, 3, 2, 2, 2, 243, 241, 3, 2, 2, 2, 243, 244, 3, 2, 2, 2, 244, 39, 3, 2, 2, 2, 245, 243, 3, 2, 2, 2, 246, 259, 5, 28, 15, 2, 247, 259, 7, 53, 2, 2, 248, 259, 7, 54, 2, 2, 249, 259, 7, 55, 2, 2, 250, 259, 7, 56, 2, 2, 251, 259, 5, 42, 22, 2, 252, 259, 5, 60, 31, 2, 253, 259, 7, 52, 2, 2, 254, 255, 7, 4, 2, 2, 255, 256, 5, 36, 19, 2, 256, 257, 7, 5, 2, 2, 257, 259, 3, 2, 2, 2, 258, 246, 3, 2, 2, 2, 258, 247, 3, 2, 2, 2, 258, 248, 3, 2, 2, 2, 258, 249, 3, 2, 2, 2, 258, 250, 3, 2, 2, 2, 258, 251, 3, 2, 2, 2, 258, 252, 3, 2, 2, 2, 258, 253, 3, 2, 2, 2, 258, 254, 3, 2, 2, 2, 259, 41, 3, 2, 2, 2, 260, 261, 7, 53, 2, 2, 261, 266, 7, 4, 2, 2, 262, 264, 7, 27, 2, 2, 263, 262, 3, 2, 2, 2, 263, 264, 3, 2, 2, 2, 264, 265, 3, 2, 2, 2, 265, 267, 5, 30, 16, 2, 266, 263, 3, 2, 2, 2, 266, 267, 3, 2, 2, 2, 267, 268, 3, 2, 2, 2, 268, 274, 7, 5, 2, 2, 269, 270, 7, 40, 2, 2, 270, 271, 7, 4, 2, 2, 271, 272, 5, 16, 9, 2, 272, 273, 7, 5, 2, 2, 273, 275, 3, 2, 2, 2, 274, 269, 3, 2, 2, 2, 274, 275, 3, 2, 2, 2, 275, 43, 3, 2, 2, 2, 276, 277, 9, 4, 2, 2, 277, 280, 7, 7, 2, 2, 278, 281, 5, 52, 27, 2, 279, 281, 5, 46, 24, 2, 280, 278, 3, 2, 2, 2, 280, 279, 3, 2, 2, 2, 281, 45, 3, 2, 2, 2, 282, 287, 5, 48, 25, 2, 283, 284, 7, 26, 2, 2, 284, 286, 5, 48, 25, 2, 285, 283, 3, 2, 2, 2, 286, 289, 3, 2, 2, 2, 287, 285, 3, 2, 2, 2, 287, 288, 3, 2, 2, 2, 288, 47, 3, 2, 2, 2, 289, 287, 3, 2, 2, 2, 290, 295, 5, 50, 26, 2, 291, 292, 7, 25, 2, 2, 292, 294, 5, 50, 26, 2, 293, 291, 3, 2, 2, 2, 294, 297, 3, 2, 2, 2, 295, 293, 3, 2, 2, 2, 295, 296, 3, 2, 2, 2, 296, 49, 3, 2, 2, 2, 297, 295, 3, 2, 2, 2, 298, 304, 5, 52, 27, 2, 299, 300, 7, 4, 2, 2, 300, 301, 5, 46, 24, 2, 301, 302, 7, 5, 2, 2, 302, 304, 3, 2, 2, 2, 303, 298, 3, 2, 2, 2, 303, 299, 3, 2, 2, 2, 304, 51, 3, 2, 2, 2, 305, 306, 5, 36, 19, 2, 306, 307, 5, 68, 35, 2, 307, 308, 5, 36, 19, 2, 308, 359, 3, 2, 2, 2, 309, 310, 5, 28, 15, 2, 310, 317, 5, 68, 35, 2, 311, 318, 5, 28, 15, 2, 312, 318, 7, 56, 2, 2, 313, 318, 7, 53, 2, 2, 314, 318, 7, 54, 2, 2, 315, 318, 7, 55, 2, 2, 316, 318, 7, 52, 2, 2, 317, 311, 3, 2, 2, 2, 317, 312, 3, 2, 2, 2, 317, 313, 3, 2, 2, 2, 317, 314, 3, 2, 2, 2, 317, 315, 3, 2, 2, 2, 317, 316, 3, 2, 2, 2, 318, 359, 3, 2, 2, 2, 319, 320, 7, 53, 2, 2, 320, 327, 5, 68, 35, 2, 321, 328, 5, 28, 15, 2, 322, 328, 7, 56, 2, 2, 323, 328, 7, 53, 2, 2, 324, 328, 7, 54, 2, 2, 325, 328, 7, 55, 2, 2, 326, 328, 7, 52, 2, 2, 327, 321, 3, 2, 2, 2, 327, 322, 3, 2, 2, 2, 327, 323, 3, 2, 2, 2, 327, 324, 3, 2, 2, 2, 327, 325, 3, 2, 2, 2, 327, 326, 3, 2, 2, 2, 328, 359, 3, 2, 2, 2, 329, 330, 7, 52, 2, 2, 330, 337, 5, 68, 35, 2, 331, 338, 5, 28, 15, 2, 332, 338, 7, 56, 2, 2, 333, 338, 7, 53, 2, 2, 334, 338, 7, 54, 2, 2, 335, 338, 7, 55, 2, 2, 336, 338, 7, 52, 2, 2, 337, 331, 3, 2, 2, 2, 337, 332, 3, 2, 2, 2, 337, 333, 3, 2, 2, 2, 337, 334, 3, 2, 2, 2, 337, 335, 3, 2, 2, 2, 337, 336, 3, 2, 2, 2, 338, 359, 3, 2, 2, 2, 339, 341, 5, 28, 15, 2, 340, 342, 5, 66, 34, 2, 341, 340, 3, 2, 2, 2, 341, 342, 3, 2, 2, 2, 342, 359, 3, 2, 2, 2, 343, 345, 7, 53, 2, 2, 344, 346, 5, 66, 34, 2, 345, 344, 3, 2, 2, 2, 345, 346, 3, 2, 2, 2, 346, 359, 3, 2, 2, 2, 347, 349, 7, 52, 2, 2, 348, 350, 5, 66, 34, 2, 349, 348, 3, 2, 2, 2, 349, 350, 3, 2, 2, 2, 350, 359, 3, 2, 2, 2, 351, 359, 7, 56, 2, 2, 352, 359, 7, 54, 2, 2, 353, 359, 7, 55, 2, 2, 354, 359, 5, 14, 8, 2, 355, 359, 5, 54, 28, 2, 356, 359, 5, 56, 29, 2, 357, 359, 5, 58, 30, 2, 358, 305, 3, 2, 2, 2, 358, 309, 3, 2, 2, 2, 358, 319, 3, 2, 2, 2, 358, 329, 3, 2, 2, 2, 358, 339, 3, 2, 2, 2, 358, 343, 3, 2, 2, 2, 358, 347, 3, 2, 2, 2, 358, 351, 3, 2, 2, 2, 358, 352, 3, 2, 2, 2, 358, 353, 3, 2, 2, 2, 358, 354, 3, 2, 2, 2, 358, 355, 3, 2, 2, 2, 358, 356, 3, 2, 2, 2, 358, 357, 3, 2, 2, 2, 359, 53, 3, 2, 2, 2, 360, 361, 7, 28, 2, 2, 361, 362, 7, 4, 2, 2, 362, 363, 5, 12, 7, 2, 363, 364, 7, 5, 2, 2, 364, 55, 3, 2, 2, 2, 365, 369, 5, 28, 15, 2, 366, 369, 7, 53, 2, 2, 367, 369, 7, 52, 2, 2, 368, 365, 3, 2, 2, 2, 368, 366, 3, 2, 2, 2, 368, 367, 3, 2, 2, 2, 369, 370, 3, 2, 2, 2, 370, 372, 7, 30, 2, 2, 371, 373, 7, 31, 2, 2, 372, 371, 3, 2, 2, 2, 372, 373, 3, 2, 2, 2, 373, 374, 3, 2, 2, 2, 374, 375, 7, 29, 2, 2, 375, 57, 3, 2, 2, 2, 376, 380, 5, 28, 15, 2, 377, 380, 7, 53, 2, 2, 378, 380, 7, 52, 2, 2, 379, 376, 3, 2, 2, 2, 379, 377, 3, 2, 2, 2, 379, 378, 3, 2, 2, 2, 380, 381, 3, 2, 2, 2, 381, 382, 7, 32, 2, 2, 382, 385, 7, 4, 2, 2, 383, 386, 5, 12, 7, 2, 384, 386, 5, 30, 16, 2, 385, 383, 3, 2, 2, 2, 385, 384, 3, 2, 2, 2, 386, 387, 3, 2, 2, 2, 387, 388, 7, 5, 2, 2, 388, 59, 3, 2, 2, 2, 389, 391, 7, 34, 2, 2, 390, 392, 5, 62, 32, 2, 391, 390, 3, 2, 2, 2, 392, 393, 3, 2, 2, 2, 393, 391, 3, 2, 2, 2, 393, 394, 3, 2, 2, 2, 394, 397, 3, 2, 2, 2, 395, 396, 7, 37, 2, 2, 396, 398, 5, 64, 33, 2, 397, 395, 3, 2, 2, 2, 397, 398, 3, 2, 2, 2, 398, 399, 3, 2, 2, 2, 399, 400, 7, 38, 2, 2, 400, 61, 3, 2, 2, 2, 401, 402, 7, 35, 2, 2, 402, 403, 5, 52, 27, 2, 403, 404, 7, 36, 2, 2, 404, 405, 5, 64, 33, 2, 405, 63, 3, 2, 2, 2, 406, 415, 5, 36, 19, 2, 407, 415, 5, 52, 27, 2, 408, 415, 5, 28, 15, 2, 409, 415, 7, 53, 2, 2, 410, 415, 7, 54, 2, 2, 411, 415, 7, 55, 2, 2, 412, 415, 7, 56, 2, 2, 413, 415, 7, 52, 2, 2, 414, 406, 3, 2, 2, 2, 414, 407, 3, 2, 2, 2, 414, 408, 3, 2, 2, 2, 414, 409, 3, 2, 2, 2, 414, 410, 3, 2, 2, 2, 414, 411, 3, 2, 2, 2, 414, 412, 3, 2, 2, 2, 414, 413, 3, 2, 2, 2, 415, 65, 3, 2, 2, 2, 416, 417, 9, 5, 2, 2, 417, 67, 3, 2, 2, 2, 418, 419, 9, 6, 2, 2, 419, 69, 3, 2, 2, 2, 44, 73, 81, 89, 110, 116, 127, 143, 146, 149, 164, 171, 174, 183, 195, 202, 218, 224, 228, 235, 243, 258, 263, 266, 274, 280, 287, 295, 303, 317, 327, 337, 341, 345, 349, 358, 368, 372, 379, 385, 393, 397, 414] \ No newline at end of file diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.tokens b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.tokens index fc5287d4..2c5313b1 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.tokens +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/Lql.tokens @@ -55,12 +55,12 @@ STRING=54 COMMENT=55 WS=56 ASTERISK=57 -'let'=1 -'='=2 -'|>'=3 -'('=4 -')'=5 -','=6 +','=1 +'('=2 +')'=3 +'let'=4 +'='=5 +'|>'=6 'fn'=7 '=>'=8 '.'=9 diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.interp b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.interp index 14330bd3..32f0afa1 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.interp +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.interp @@ -1,11 +1,11 @@ token literal names: null +',' +'(' +')' 'let' '=' '|>' -'(' -')' -',' 'fn' '=>' '.' @@ -211,4 +211,4 @@ mode names: DEFAULT_MODE atn: -[3, 24715, 42794, 33075, 47597, 16764, 15335, 30598, 22884, 2, 59, 499, 8, 1, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 4, 18, 9, 18, 4, 19, 9, 19, 4, 20, 9, 20, 4, 21, 9, 21, 4, 22, 9, 22, 4, 23, 9, 23, 4, 24, 9, 24, 4, 25, 9, 25, 4, 26, 9, 26, 4, 27, 9, 27, 4, 28, 9, 28, 4, 29, 9, 29, 4, 30, 9, 30, 4, 31, 9, 31, 4, 32, 9, 32, 4, 33, 9, 33, 4, 34, 9, 34, 4, 35, 9, 35, 4, 36, 9, 36, 4, 37, 9, 37, 4, 38, 9, 38, 4, 39, 9, 39, 4, 40, 9, 40, 4, 41, 9, 41, 4, 42, 9, 42, 4, 43, 9, 43, 4, 44, 9, 44, 4, 45, 9, 45, 4, 46, 9, 46, 4, 47, 9, 47, 4, 48, 9, 48, 4, 49, 9, 49, 4, 50, 9, 50, 4, 51, 9, 51, 4, 52, 9, 52, 4, 53, 9, 53, 4, 54, 9, 54, 4, 55, 9, 55, 4, 56, 9, 56, 4, 57, 9, 57, 4, 58, 9, 58, 4, 59, 9, 59, 4, 60, 9, 60, 4, 61, 9, 61, 4, 62, 9, 62, 4, 63, 9, 63, 4, 64, 9, 64, 4, 65, 9, 65, 4, 66, 9, 66, 4, 67, 9, 67, 4, 68, 9, 68, 4, 69, 9, 69, 4, 70, 9, 70, 4, 71, 9, 71, 4, 72, 9, 72, 4, 73, 9, 73, 4, 74, 9, 74, 4, 75, 9, 75, 4, 76, 9, 76, 4, 77, 9, 77, 4, 78, 9, 78, 4, 79, 9, 79, 4, 80, 9, 80, 4, 81, 9, 81, 4, 82, 9, 82, 4, 83, 9, 83, 4, 84, 9, 84, 3, 2, 3, 2, 3, 2, 3, 2, 3, 3, 3, 3, 3, 4, 3, 4, 3, 4, 3, 5, 3, 5, 3, 6, 3, 6, 3, 7, 3, 7, 3, 8, 3, 8, 3, 8, 3, 9, 3, 9, 3, 9, 3, 10, 3, 10, 3, 11, 3, 11, 3, 12, 3, 12, 3, 13, 3, 13, 3, 13, 3, 14, 3, 14, 3, 15, 3, 15, 3, 16, 3, 16, 3, 16, 3, 17, 3, 17, 3, 17, 3, 18, 3, 18, 3, 19, 3, 19, 3, 20, 3, 20, 3, 20, 3, 21, 3, 21, 3, 21, 3, 22, 3, 22, 3, 22, 3, 22, 3, 23, 3, 23, 3, 23, 3, 23, 3, 23, 3, 24, 3, 24, 3, 24, 3, 24, 3, 25, 3, 25, 3, 25, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 28, 3, 28, 3, 28, 3, 28, 3, 28, 3, 29, 3, 29, 3, 29, 3, 30, 3, 30, 3, 30, 3, 30, 3, 31, 3, 31, 3, 31, 3, 32, 3, 32, 3, 32, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 3, 34, 3, 34, 3, 34, 3, 34, 3, 34, 3, 35, 3, 35, 3, 35, 3, 35, 3, 35, 3, 36, 3, 36, 3, 36, 3, 36, 3, 36, 3, 37, 3, 37, 3, 37, 3, 37, 3, 38, 3, 38, 3, 38, 3, 38, 3, 38, 3, 39, 3, 39, 3, 39, 3, 39, 3, 39, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 41, 3, 41, 3, 41, 3, 41, 3, 41, 3, 41, 3, 42, 3, 42, 3, 42, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 45, 3, 45, 3, 45, 3, 45, 3, 45, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 49, 3, 49, 3, 49, 3, 50, 3, 50, 3, 50, 3, 50, 3, 50, 3, 51, 3, 51, 3, 52, 3, 52, 3, 53, 3, 53, 3, 54, 3, 54, 3, 55, 3, 55, 3, 56, 3, 56, 3, 57, 3, 57, 3, 58, 3, 58, 3, 59, 3, 59, 3, 60, 3, 60, 3, 61, 3, 61, 3, 62, 3, 62, 3, 63, 3, 63, 3, 64, 3, 64, 3, 65, 3, 65, 3, 66, 3, 66, 3, 67, 3, 67, 3, 68, 3, 68, 3, 69, 3, 69, 3, 70, 3, 70, 3, 71, 3, 71, 3, 72, 3, 72, 3, 73, 3, 73, 3, 74, 3, 74, 3, 75, 3, 75, 3, 76, 3, 76, 3, 77, 3, 77, 3, 77, 7, 77, 441, 10, 77, 12, 77, 14, 77, 444, 11, 77, 3, 78, 3, 78, 7, 78, 448, 10, 78, 12, 78, 14, 78, 451, 11, 78, 3, 79, 6, 79, 454, 10, 79, 13, 79, 14, 79, 455, 3, 80, 6, 80, 459, 10, 80, 13, 80, 14, 80, 460, 3, 80, 3, 80, 6, 80, 465, 10, 80, 13, 80, 14, 80, 466, 3, 81, 3, 81, 3, 81, 3, 81, 7, 81, 473, 10, 81, 12, 81, 14, 81, 476, 11, 81, 3, 81, 3, 81, 3, 82, 3, 82, 3, 82, 3, 82, 7, 82, 484, 10, 82, 12, 82, 14, 82, 487, 11, 82, 3, 82, 3, 82, 3, 83, 6, 83, 492, 10, 83, 13, 83, 14, 83, 493, 3, 83, 3, 83, 3, 84, 3, 84, 2, 2, 85, 3, 3, 5, 4, 7, 5, 9, 6, 11, 7, 13, 8, 15, 9, 17, 10, 19, 11, 21, 12, 23, 13, 25, 14, 27, 15, 29, 16, 31, 17, 33, 18, 35, 19, 37, 20, 39, 21, 41, 22, 43, 23, 45, 24, 47, 25, 49, 26, 51, 27, 53, 28, 55, 29, 57, 30, 59, 31, 61, 32, 63, 33, 65, 34, 67, 35, 69, 36, 71, 37, 73, 38, 75, 39, 77, 40, 79, 41, 81, 42, 83, 43, 85, 44, 87, 45, 89, 46, 91, 47, 93, 48, 95, 49, 97, 50, 99, 51, 101, 2, 103, 2, 105, 2, 107, 2, 109, 2, 111, 2, 113, 2, 115, 2, 117, 2, 119, 2, 121, 2, 123, 2, 125, 2, 127, 2, 129, 2, 131, 2, 133, 2, 135, 2, 137, 2, 139, 2, 141, 2, 143, 2, 145, 2, 147, 2, 149, 2, 151, 2, 153, 52, 155, 53, 157, 54, 159, 55, 161, 56, 163, 57, 165, 58, 167, 59, 3, 2, 34, 4, 2, 67, 67, 99, 99, 4, 2, 68, 68, 100, 100, 4, 2, 69, 69, 101, 101, 4, 2, 70, 70, 102, 102, 4, 2, 71, 71, 103, 103, 4, 2, 72, 72, 104, 104, 4, 2, 73, 73, 105, 105, 4, 2, 74, 74, 106, 106, 4, 2, 75, 75, 107, 107, 4, 2, 76, 76, 108, 108, 4, 2, 77, 77, 109, 109, 4, 2, 78, 78, 110, 110, 4, 2, 79, 79, 111, 111, 4, 2, 80, 80, 112, 112, 4, 2, 81, 81, 113, 113, 4, 2, 82, 82, 114, 114, 4, 2, 83, 83, 115, 115, 4, 2, 84, 84, 116, 116, 4, 2, 85, 85, 117, 117, 4, 2, 86, 86, 118, 118, 4, 2, 87, 87, 119, 119, 4, 2, 88, 88, 120, 120, 4, 2, 89, 89, 121, 121, 4, 2, 90, 90, 122, 122, 4, 2, 91, 91, 123, 123, 4, 2, 92, 92, 124, 124, 5, 2, 67, 92, 97, 97, 99, 124, 6, 2, 50, 59, 67, 92, 97, 97, 99, 124, 3, 2, 50, 59, 4, 2, 41, 41, 94, 94, 4, 2, 12, 12, 15, 15, 5, 2, 11, 12, 15, 15, 34, 34, 2, 481, 2, 3, 3, 2, 2, 2, 2, 5, 3, 2, 2, 2, 2, 7, 3, 2, 2, 2, 2, 9, 3, 2, 2, 2, 2, 11, 3, 2, 2, 2, 2, 13, 3, 2, 2, 2, 2, 15, 3, 2, 2, 2, 2, 17, 3, 2, 2, 2, 2, 19, 3, 2, 2, 2, 2, 21, 3, 2, 2, 2, 2, 23, 3, 2, 2, 2, 2, 25, 3, 2, 2, 2, 2, 27, 3, 2, 2, 2, 2, 29, 3, 2, 2, 2, 2, 31, 3, 2, 2, 2, 2, 33, 3, 2, 2, 2, 2, 35, 3, 2, 2, 2, 2, 37, 3, 2, 2, 2, 2, 39, 3, 2, 2, 2, 2, 41, 3, 2, 2, 2, 2, 43, 3, 2, 2, 2, 2, 45, 3, 2, 2, 2, 2, 47, 3, 2, 2, 2, 2, 49, 3, 2, 2, 2, 2, 51, 3, 2, 2, 2, 2, 53, 3, 2, 2, 2, 2, 55, 3, 2, 2, 2, 2, 57, 3, 2, 2, 2, 2, 59, 3, 2, 2, 2, 2, 61, 3, 2, 2, 2, 2, 63, 3, 2, 2, 2, 2, 65, 3, 2, 2, 2, 2, 67, 3, 2, 2, 2, 2, 69, 3, 2, 2, 2, 2, 71, 3, 2, 2, 2, 2, 73, 3, 2, 2, 2, 2, 75, 3, 2, 2, 2, 2, 77, 3, 2, 2, 2, 2, 79, 3, 2, 2, 2, 2, 81, 3, 2, 2, 2, 2, 83, 3, 2, 2, 2, 2, 85, 3, 2, 2, 2, 2, 87, 3, 2, 2, 2, 2, 89, 3, 2, 2, 2, 2, 91, 3, 2, 2, 2, 2, 93, 3, 2, 2, 2, 2, 95, 3, 2, 2, 2, 2, 97, 3, 2, 2, 2, 2, 99, 3, 2, 2, 2, 2, 153, 3, 2, 2, 2, 2, 155, 3, 2, 2, 2, 2, 157, 3, 2, 2, 2, 2, 159, 3, 2, 2, 2, 2, 161, 3, 2, 2, 2, 2, 163, 3, 2, 2, 2, 2, 165, 3, 2, 2, 2, 2, 167, 3, 2, 2, 2, 3, 169, 3, 2, 2, 2, 5, 173, 3, 2, 2, 2, 7, 175, 3, 2, 2, 2, 9, 178, 3, 2, 2, 2, 11, 180, 3, 2, 2, 2, 13, 182, 3, 2, 2, 2, 15, 184, 3, 2, 2, 2, 17, 187, 3, 2, 2, 2, 19, 190, 3, 2, 2, 2, 21, 192, 3, 2, 2, 2, 23, 194, 3, 2, 2, 2, 25, 196, 3, 2, 2, 2, 27, 199, 3, 2, 2, 2, 29, 201, 3, 2, 2, 2, 31, 203, 3, 2, 2, 2, 33, 206, 3, 2, 2, 2, 35, 209, 3, 2, 2, 2, 37, 211, 3, 2, 2, 2, 39, 213, 3, 2, 2, 2, 41, 216, 3, 2, 2, 2, 43, 219, 3, 2, 2, 2, 45, 223, 3, 2, 2, 2, 47, 228, 3, 2, 2, 2, 49, 232, 3, 2, 2, 2, 51, 235, 3, 2, 2, 2, 53, 244, 3, 2, 2, 2, 55, 251, 3, 2, 2, 2, 57, 256, 3, 2, 2, 2, 59, 259, 3, 2, 2, 2, 61, 263, 3, 2, 2, 2, 63, 266, 3, 2, 2, 2, 65, 269, 3, 2, 2, 2, 67, 274, 3, 2, 2, 2, 69, 279, 3, 2, 2, 2, 71, 284, 3, 2, 2, 2, 73, 289, 3, 2, 2, 2, 75, 293, 3, 2, 2, 2, 77, 298, 3, 2, 2, 2, 79, 303, 3, 2, 2, 2, 81, 313, 3, 2, 2, 2, 83, 319, 3, 2, 2, 2, 85, 322, 3, 2, 2, 2, 87, 331, 3, 2, 2, 2, 89, 339, 3, 2, 2, 2, 91, 344, 3, 2, 2, 2, 93, 353, 3, 2, 2, 2, 95, 366, 3, 2, 2, 2, 97, 377, 3, 2, 2, 2, 99, 380, 3, 2, 2, 2, 101, 385, 3, 2, 2, 2, 103, 387, 3, 2, 2, 2, 105, 389, 3, 2, 2, 2, 107, 391, 3, 2, 2, 2, 109, 393, 3, 2, 2, 2, 111, 395, 3, 2, 2, 2, 113, 397, 3, 2, 2, 2, 115, 399, 3, 2, 2, 2, 117, 401, 3, 2, 2, 2, 119, 403, 3, 2, 2, 2, 121, 405, 3, 2, 2, 2, 123, 407, 3, 2, 2, 2, 125, 409, 3, 2, 2, 2, 127, 411, 3, 2, 2, 2, 129, 413, 3, 2, 2, 2, 131, 415, 3, 2, 2, 2, 133, 417, 3, 2, 2, 2, 135, 419, 3, 2, 2, 2, 137, 421, 3, 2, 2, 2, 139, 423, 3, 2, 2, 2, 141, 425, 3, 2, 2, 2, 143, 427, 3, 2, 2, 2, 145, 429, 3, 2, 2, 2, 147, 431, 3, 2, 2, 2, 149, 433, 3, 2, 2, 2, 151, 435, 3, 2, 2, 2, 153, 437, 3, 2, 2, 2, 155, 445, 3, 2, 2, 2, 157, 453, 3, 2, 2, 2, 159, 458, 3, 2, 2, 2, 161, 468, 3, 2, 2, 2, 163, 479, 3, 2, 2, 2, 165, 491, 3, 2, 2, 2, 167, 497, 3, 2, 2, 2, 169, 170, 7, 110, 2, 2, 170, 171, 7, 103, 2, 2, 171, 172, 7, 118, 2, 2, 172, 4, 3, 2, 2, 2, 173, 174, 7, 63, 2, 2, 174, 6, 3, 2, 2, 2, 175, 176, 7, 126, 2, 2, 176, 177, 7, 64, 2, 2, 177, 8, 3, 2, 2, 2, 178, 179, 7, 42, 2, 2, 179, 10, 3, 2, 2, 2, 180, 181, 7, 43, 2, 2, 181, 12, 3, 2, 2, 2, 182, 183, 7, 46, 2, 2, 183, 14, 3, 2, 2, 2, 184, 185, 7, 104, 2, 2, 185, 186, 7, 112, 2, 2, 186, 16, 3, 2, 2, 2, 187, 188, 7, 63, 2, 2, 188, 189, 7, 64, 2, 2, 189, 18, 3, 2, 2, 2, 190, 191, 7, 48, 2, 2, 191, 20, 3, 2, 2, 2, 192, 193, 7, 45, 2, 2, 193, 22, 3, 2, 2, 2, 194, 195, 7, 47, 2, 2, 195, 24, 3, 2, 2, 2, 196, 197, 7, 126, 2, 2, 197, 198, 7, 126, 2, 2, 198, 26, 3, 2, 2, 2, 199, 200, 7, 49, 2, 2, 200, 28, 3, 2, 2, 2, 201, 202, 7, 39, 2, 2, 202, 30, 3, 2, 2, 2, 203, 204, 7, 35, 2, 2, 204, 205, 7, 63, 2, 2, 205, 32, 3, 2, 2, 2, 206, 207, 7, 62, 2, 2, 207, 208, 7, 64, 2, 2, 208, 34, 3, 2, 2, 2, 209, 210, 7, 62, 2, 2, 210, 36, 3, 2, 2, 2, 211, 212, 7, 64, 2, 2, 212, 38, 3, 2, 2, 2, 213, 214, 7, 62, 2, 2, 214, 215, 7, 63, 2, 2, 215, 40, 3, 2, 2, 2, 216, 217, 7, 64, 2, 2, 217, 218, 7, 63, 2, 2, 218, 42, 3, 2, 2, 2, 219, 220, 5, 101, 51, 2, 220, 221, 5, 137, 69, 2, 221, 222, 5, 105, 53, 2, 222, 44, 3, 2, 2, 2, 223, 224, 5, 107, 54, 2, 224, 225, 5, 109, 55, 2, 225, 226, 5, 137, 69, 2, 226, 227, 5, 105, 53, 2, 227, 46, 3, 2, 2, 2, 228, 229, 5, 101, 51, 2, 229, 230, 5, 127, 64, 2, 230, 231, 5, 107, 54, 2, 231, 48, 3, 2, 2, 2, 232, 233, 5, 129, 65, 2, 233, 234, 5, 135, 68, 2, 234, 50, 3, 2, 2, 2, 235, 236, 5, 107, 54, 2, 236, 237, 5, 117, 59, 2, 237, 238, 5, 137, 69, 2, 238, 239, 5, 139, 70, 2, 239, 240, 5, 117, 59, 2, 240, 241, 5, 127, 64, 2, 241, 242, 5, 105, 53, 2, 242, 243, 5, 139, 70, 2, 243, 52, 3, 2, 2, 2, 244, 245, 5, 109, 55, 2, 245, 246, 5, 147, 74, 2, 246, 247, 5, 117, 59, 2, 247, 248, 5, 137, 69, 2, 248, 249, 5, 139, 70, 2, 249, 250, 5, 137, 69, 2, 250, 54, 3, 2, 2, 2, 251, 252, 5, 127, 64, 2, 252, 253, 5, 141, 71, 2, 253, 254, 5, 123, 62, 2, 254, 255, 5, 123, 62, 2, 255, 56, 3, 2, 2, 2, 256, 257, 5, 117, 59, 2, 257, 258, 5, 137, 69, 2, 258, 58, 3, 2, 2, 2, 259, 260, 5, 127, 64, 2, 260, 261, 5, 129, 65, 2, 261, 262, 5, 139, 70, 2, 262, 60, 3, 2, 2, 2, 263, 264, 5, 117, 59, 2, 264, 265, 5, 127, 64, 2, 265, 62, 3, 2, 2, 2, 266, 267, 5, 101, 51, 2, 267, 268, 5, 137, 69, 2, 268, 64, 3, 2, 2, 2, 269, 270, 5, 105, 53, 2, 270, 271, 5, 101, 51, 2, 271, 272, 5, 137, 69, 2, 272, 273, 5, 109, 55, 2, 273, 66, 3, 2, 2, 2, 274, 275, 5, 145, 73, 2, 275, 276, 5, 115, 58, 2, 276, 277, 5, 109, 55, 2, 277, 278, 5, 127, 64, 2, 278, 68, 3, 2, 2, 2, 279, 280, 5, 139, 70, 2, 280, 281, 5, 115, 58, 2, 281, 282, 5, 109, 55, 2, 282, 283, 5, 127, 64, 2, 283, 70, 3, 2, 2, 2, 284, 285, 5, 109, 55, 2, 285, 286, 5, 123, 62, 2, 286, 287, 5, 137, 69, 2, 287, 288, 5, 109, 55, 2, 288, 72, 3, 2, 2, 2, 289, 290, 5, 109, 55, 2, 290, 291, 5, 127, 64, 2, 291, 292, 5, 107, 54, 2, 292, 74, 3, 2, 2, 2, 293, 294, 5, 145, 73, 2, 294, 295, 5, 117, 59, 2, 295, 296, 5, 139, 70, 2, 296, 297, 5, 115, 58, 2, 297, 76, 3, 2, 2, 2, 298, 299, 5, 129, 65, 2, 299, 300, 5, 143, 72, 2, 300, 301, 5, 109, 55, 2, 301, 302, 5, 135, 68, 2, 302, 78, 3, 2, 2, 2, 303, 304, 5, 131, 66, 2, 304, 305, 5, 101, 51, 2, 305, 306, 5, 135, 68, 2, 306, 307, 5, 139, 70, 2, 307, 308, 5, 117, 59, 2, 308, 309, 5, 139, 70, 2, 309, 310, 5, 117, 59, 2, 310, 311, 5, 129, 65, 2, 311, 312, 5, 127, 64, 2, 312, 80, 3, 2, 2, 2, 313, 314, 5, 129, 65, 2, 314, 315, 5, 135, 68, 2, 315, 316, 5, 107, 54, 2, 316, 317, 5, 109, 55, 2, 317, 318, 5, 135, 68, 2, 318, 82, 3, 2, 2, 2, 319, 320, 5, 103, 52, 2, 320, 321, 5, 149, 75, 2, 321, 84, 3, 2, 2, 2, 322, 323, 5, 105, 53, 2, 323, 324, 5, 129, 65, 2, 324, 325, 5, 101, 51, 2, 325, 326, 5, 123, 62, 2, 326, 327, 5, 109, 55, 2, 327, 328, 5, 137, 69, 2, 328, 329, 5, 105, 53, 2, 329, 330, 5, 109, 55, 2, 330, 86, 3, 2, 2, 2, 331, 332, 5, 109, 55, 2, 332, 333, 5, 147, 74, 2, 333, 334, 5, 139, 70, 2, 334, 335, 5, 135, 68, 2, 335, 336, 5, 101, 51, 2, 336, 337, 5, 105, 53, 2, 337, 338, 5, 139, 70, 2, 338, 88, 3, 2, 2, 2, 339, 340, 5, 111, 56, 2, 340, 341, 5, 135, 68, 2, 341, 342, 5, 129, 65, 2, 342, 343, 5, 125, 63, 2, 343, 90, 3, 2, 2, 2, 344, 345, 5, 117, 59, 2, 345, 346, 5, 127, 64, 2, 346, 347, 5, 139, 70, 2, 347, 348, 5, 109, 55, 2, 348, 349, 5, 135, 68, 2, 349, 350, 5, 143, 72, 2, 350, 351, 5, 101, 51, 2, 351, 352, 5, 123, 62, 2, 352, 92, 3, 2, 2, 2, 353, 354, 5, 105, 53, 2, 354, 355, 5, 141, 71, 2, 355, 356, 5, 135, 68, 2, 356, 357, 5, 135, 68, 2, 357, 358, 5, 109, 55, 2, 358, 359, 5, 127, 64, 2, 359, 360, 5, 139, 70, 2, 360, 361, 7, 97, 2, 2, 361, 362, 5, 107, 54, 2, 362, 363, 5, 101, 51, 2, 363, 364, 5, 139, 70, 2, 364, 365, 5, 109, 55, 2, 365, 94, 3, 2, 2, 2, 366, 367, 5, 107, 54, 2, 367, 368, 5, 101, 51, 2, 368, 369, 5, 139, 70, 2, 369, 370, 5, 109, 55, 2, 370, 371, 7, 97, 2, 2, 371, 372, 5, 139, 70, 2, 372, 373, 5, 135, 68, 2, 373, 374, 5, 141, 71, 2, 374, 375, 5, 127, 64, 2, 375, 376, 5, 105, 53, 2, 376, 96, 3, 2, 2, 2, 377, 378, 5, 129, 65, 2, 378, 379, 5, 127, 64, 2, 379, 98, 3, 2, 2, 2, 380, 381, 5, 123, 62, 2, 381, 382, 5, 117, 59, 2, 382, 383, 5, 121, 61, 2, 383, 384, 5, 109, 55, 2, 384, 100, 3, 2, 2, 2, 385, 386, 9, 2, 2, 2, 386, 102, 3, 2, 2, 2, 387, 388, 9, 3, 2, 2, 388, 104, 3, 2, 2, 2, 389, 390, 9, 4, 2, 2, 390, 106, 3, 2, 2, 2, 391, 392, 9, 5, 2, 2, 392, 108, 3, 2, 2, 2, 393, 394, 9, 6, 2, 2, 394, 110, 3, 2, 2, 2, 395, 396, 9, 7, 2, 2, 396, 112, 3, 2, 2, 2, 397, 398, 9, 8, 2, 2, 398, 114, 3, 2, 2, 2, 399, 400, 9, 9, 2, 2, 400, 116, 3, 2, 2, 2, 401, 402, 9, 10, 2, 2, 402, 118, 3, 2, 2, 2, 403, 404, 9, 11, 2, 2, 404, 120, 3, 2, 2, 2, 405, 406, 9, 12, 2, 2, 406, 122, 3, 2, 2, 2, 407, 408, 9, 13, 2, 2, 408, 124, 3, 2, 2, 2, 409, 410, 9, 14, 2, 2, 410, 126, 3, 2, 2, 2, 411, 412, 9, 15, 2, 2, 412, 128, 3, 2, 2, 2, 413, 414, 9, 16, 2, 2, 414, 130, 3, 2, 2, 2, 415, 416, 9, 17, 2, 2, 416, 132, 3, 2, 2, 2, 417, 418, 9, 18, 2, 2, 418, 134, 3, 2, 2, 2, 419, 420, 9, 19, 2, 2, 420, 136, 3, 2, 2, 2, 421, 422, 9, 20, 2, 2, 422, 138, 3, 2, 2, 2, 423, 424, 9, 21, 2, 2, 424, 140, 3, 2, 2, 2, 425, 426, 9, 22, 2, 2, 426, 142, 3, 2, 2, 2, 427, 428, 9, 23, 2, 2, 428, 144, 3, 2, 2, 2, 429, 430, 9, 24, 2, 2, 430, 146, 3, 2, 2, 2, 431, 432, 9, 25, 2, 2, 432, 148, 3, 2, 2, 2, 433, 434, 9, 26, 2, 2, 434, 150, 3, 2, 2, 2, 435, 436, 9, 27, 2, 2, 436, 152, 3, 2, 2, 2, 437, 438, 7, 66, 2, 2, 438, 442, 9, 28, 2, 2, 439, 441, 9, 29, 2, 2, 440, 439, 3, 2, 2, 2, 441, 444, 3, 2, 2, 2, 442, 440, 3, 2, 2, 2, 442, 443, 3, 2, 2, 2, 443, 154, 3, 2, 2, 2, 444, 442, 3, 2, 2, 2, 445, 449, 9, 28, 2, 2, 446, 448, 9, 29, 2, 2, 447, 446, 3, 2, 2, 2, 448, 451, 3, 2, 2, 2, 449, 447, 3, 2, 2, 2, 449, 450, 3, 2, 2, 2, 450, 156, 3, 2, 2, 2, 451, 449, 3, 2, 2, 2, 452, 454, 9, 30, 2, 2, 453, 452, 3, 2, 2, 2, 454, 455, 3, 2, 2, 2, 455, 453, 3, 2, 2, 2, 455, 456, 3, 2, 2, 2, 456, 158, 3, 2, 2, 2, 457, 459, 9, 30, 2, 2, 458, 457, 3, 2, 2, 2, 459, 460, 3, 2, 2, 2, 460, 458, 3, 2, 2, 2, 460, 461, 3, 2, 2, 2, 461, 462, 3, 2, 2, 2, 462, 464, 7, 48, 2, 2, 463, 465, 9, 30, 2, 2, 464, 463, 3, 2, 2, 2, 465, 466, 3, 2, 2, 2, 466, 464, 3, 2, 2, 2, 466, 467, 3, 2, 2, 2, 467, 160, 3, 2, 2, 2, 468, 474, 7, 41, 2, 2, 469, 473, 10, 31, 2, 2, 470, 471, 7, 94, 2, 2, 471, 473, 11, 2, 2, 2, 472, 469, 3, 2, 2, 2, 472, 470, 3, 2, 2, 2, 473, 476, 3, 2, 2, 2, 474, 472, 3, 2, 2, 2, 474, 475, 3, 2, 2, 2, 475, 477, 3, 2, 2, 2, 476, 474, 3, 2, 2, 2, 477, 478, 7, 41, 2, 2, 478, 162, 3, 2, 2, 2, 479, 480, 7, 47, 2, 2, 480, 481, 7, 47, 2, 2, 481, 485, 3, 2, 2, 2, 482, 484, 10, 32, 2, 2, 483, 482, 3, 2, 2, 2, 484, 487, 3, 2, 2, 2, 485, 483, 3, 2, 2, 2, 485, 486, 3, 2, 2, 2, 486, 488, 3, 2, 2, 2, 487, 485, 3, 2, 2, 2, 488, 489, 8, 82, 2, 2, 489, 164, 3, 2, 2, 2, 490, 492, 9, 33, 2, 2, 491, 490, 3, 2, 2, 2, 492, 493, 3, 2, 2, 2, 493, 491, 3, 2, 2, 2, 493, 494, 3, 2, 2, 2, 494, 495, 3, 2, 2, 2, 495, 496, 8, 83, 2, 2, 496, 166, 3, 2, 2, 2, 497, 498, 7, 44, 2, 2, 498, 168, 3, 2, 2, 2, 12, 2, 442, 449, 455, 460, 466, 472, 474, 485, 493, 3, 8, 2, 2] \ No newline at end of file +[3, 24715, 42794, 33075, 47597, 16764, 15335, 30598, 22884, 2, 59, 499, 8, 1, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 4, 18, 9, 18, 4, 19, 9, 19, 4, 20, 9, 20, 4, 21, 9, 21, 4, 22, 9, 22, 4, 23, 9, 23, 4, 24, 9, 24, 4, 25, 9, 25, 4, 26, 9, 26, 4, 27, 9, 27, 4, 28, 9, 28, 4, 29, 9, 29, 4, 30, 9, 30, 4, 31, 9, 31, 4, 32, 9, 32, 4, 33, 9, 33, 4, 34, 9, 34, 4, 35, 9, 35, 4, 36, 9, 36, 4, 37, 9, 37, 4, 38, 9, 38, 4, 39, 9, 39, 4, 40, 9, 40, 4, 41, 9, 41, 4, 42, 9, 42, 4, 43, 9, 43, 4, 44, 9, 44, 4, 45, 9, 45, 4, 46, 9, 46, 4, 47, 9, 47, 4, 48, 9, 48, 4, 49, 9, 49, 4, 50, 9, 50, 4, 51, 9, 51, 4, 52, 9, 52, 4, 53, 9, 53, 4, 54, 9, 54, 4, 55, 9, 55, 4, 56, 9, 56, 4, 57, 9, 57, 4, 58, 9, 58, 4, 59, 9, 59, 4, 60, 9, 60, 4, 61, 9, 61, 4, 62, 9, 62, 4, 63, 9, 63, 4, 64, 9, 64, 4, 65, 9, 65, 4, 66, 9, 66, 4, 67, 9, 67, 4, 68, 9, 68, 4, 69, 9, 69, 4, 70, 9, 70, 4, 71, 9, 71, 4, 72, 9, 72, 4, 73, 9, 73, 4, 74, 9, 74, 4, 75, 9, 75, 4, 76, 9, 76, 4, 77, 9, 77, 4, 78, 9, 78, 4, 79, 9, 79, 4, 80, 9, 80, 4, 81, 9, 81, 4, 82, 9, 82, 4, 83, 9, 83, 4, 84, 9, 84, 3, 2, 3, 2, 3, 3, 3, 3, 3, 4, 3, 4, 3, 5, 3, 5, 3, 5, 3, 5, 3, 6, 3, 6, 3, 7, 3, 7, 3, 7, 3, 8, 3, 8, 3, 8, 3, 9, 3, 9, 3, 9, 3, 10, 3, 10, 3, 11, 3, 11, 3, 12, 3, 12, 3, 13, 3, 13, 3, 13, 3, 14, 3, 14, 3, 15, 3, 15, 3, 16, 3, 16, 3, 16, 3, 17, 3, 17, 3, 17, 3, 18, 3, 18, 3, 19, 3, 19, 3, 20, 3, 20, 3, 20, 3, 21, 3, 21, 3, 21, 3, 22, 3, 22, 3, 22, 3, 22, 3, 23, 3, 23, 3, 23, 3, 23, 3, 23, 3, 24, 3, 24, 3, 24, 3, 24, 3, 25, 3, 25, 3, 25, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 26, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 27, 3, 28, 3, 28, 3, 28, 3, 28, 3, 28, 3, 29, 3, 29, 3, 29, 3, 30, 3, 30, 3, 30, 3, 30, 3, 31, 3, 31, 3, 31, 3, 32, 3, 32, 3, 32, 3, 33, 3, 33, 3, 33, 3, 33, 3, 33, 3, 34, 3, 34, 3, 34, 3, 34, 3, 34, 3, 35, 3, 35, 3, 35, 3, 35, 3, 35, 3, 36, 3, 36, 3, 36, 3, 36, 3, 36, 3, 37, 3, 37, 3, 37, 3, 37, 3, 38, 3, 38, 3, 38, 3, 38, 3, 38, 3, 39, 3, 39, 3, 39, 3, 39, 3, 39, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 40, 3, 41, 3, 41, 3, 41, 3, 41, 3, 41, 3, 41, 3, 42, 3, 42, 3, 42, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 43, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 44, 3, 45, 3, 45, 3, 45, 3, 45, 3, 45, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 46, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 47, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 48, 3, 49, 3, 49, 3, 49, 3, 50, 3, 50, 3, 50, 3, 50, 3, 50, 3, 51, 3, 51, 3, 52, 3, 52, 3, 53, 3, 53, 3, 54, 3, 54, 3, 55, 3, 55, 3, 56, 3, 56, 3, 57, 3, 57, 3, 58, 3, 58, 3, 59, 3, 59, 3, 60, 3, 60, 3, 61, 3, 61, 3, 62, 3, 62, 3, 63, 3, 63, 3, 64, 3, 64, 3, 65, 3, 65, 3, 66, 3, 66, 3, 67, 3, 67, 3, 68, 3, 68, 3, 69, 3, 69, 3, 70, 3, 70, 3, 71, 3, 71, 3, 72, 3, 72, 3, 73, 3, 73, 3, 74, 3, 74, 3, 75, 3, 75, 3, 76, 3, 76, 3, 77, 3, 77, 3, 77, 7, 77, 441, 10, 77, 12, 77, 14, 77, 444, 11, 77, 3, 78, 3, 78, 7, 78, 448, 10, 78, 12, 78, 14, 78, 451, 11, 78, 3, 79, 6, 79, 454, 10, 79, 13, 79, 14, 79, 455, 3, 80, 6, 80, 459, 10, 80, 13, 80, 14, 80, 460, 3, 80, 3, 80, 6, 80, 465, 10, 80, 13, 80, 14, 80, 466, 3, 81, 3, 81, 3, 81, 3, 81, 7, 81, 473, 10, 81, 12, 81, 14, 81, 476, 11, 81, 3, 81, 3, 81, 3, 82, 3, 82, 3, 82, 3, 82, 7, 82, 484, 10, 82, 12, 82, 14, 82, 487, 11, 82, 3, 82, 3, 82, 3, 83, 6, 83, 492, 10, 83, 13, 83, 14, 83, 493, 3, 83, 3, 83, 3, 84, 3, 84, 2, 2, 85, 3, 3, 5, 4, 7, 5, 9, 6, 11, 7, 13, 8, 15, 9, 17, 10, 19, 11, 21, 12, 23, 13, 25, 14, 27, 15, 29, 16, 31, 17, 33, 18, 35, 19, 37, 20, 39, 21, 41, 22, 43, 23, 45, 24, 47, 25, 49, 26, 51, 27, 53, 28, 55, 29, 57, 30, 59, 31, 61, 32, 63, 33, 65, 34, 67, 35, 69, 36, 71, 37, 73, 38, 75, 39, 77, 40, 79, 41, 81, 42, 83, 43, 85, 44, 87, 45, 89, 46, 91, 47, 93, 48, 95, 49, 97, 50, 99, 51, 101, 2, 103, 2, 105, 2, 107, 2, 109, 2, 111, 2, 113, 2, 115, 2, 117, 2, 119, 2, 121, 2, 123, 2, 125, 2, 127, 2, 129, 2, 131, 2, 133, 2, 135, 2, 137, 2, 139, 2, 141, 2, 143, 2, 145, 2, 147, 2, 149, 2, 151, 2, 153, 52, 155, 53, 157, 54, 159, 55, 161, 56, 163, 57, 165, 58, 167, 59, 3, 2, 34, 4, 2, 67, 67, 99, 99, 4, 2, 68, 68, 100, 100, 4, 2, 69, 69, 101, 101, 4, 2, 70, 70, 102, 102, 4, 2, 71, 71, 103, 103, 4, 2, 72, 72, 104, 104, 4, 2, 73, 73, 105, 105, 4, 2, 74, 74, 106, 106, 4, 2, 75, 75, 107, 107, 4, 2, 76, 76, 108, 108, 4, 2, 77, 77, 109, 109, 4, 2, 78, 78, 110, 110, 4, 2, 79, 79, 111, 111, 4, 2, 80, 80, 112, 112, 4, 2, 81, 81, 113, 113, 4, 2, 82, 82, 114, 114, 4, 2, 83, 83, 115, 115, 4, 2, 84, 84, 116, 116, 4, 2, 85, 85, 117, 117, 4, 2, 86, 86, 118, 118, 4, 2, 87, 87, 119, 119, 4, 2, 88, 88, 120, 120, 4, 2, 89, 89, 121, 121, 4, 2, 90, 90, 122, 122, 4, 2, 91, 91, 123, 123, 4, 2, 92, 92, 124, 124, 5, 2, 67, 92, 97, 97, 99, 124, 6, 2, 50, 59, 67, 92, 97, 97, 99, 124, 3, 2, 50, 59, 4, 2, 41, 41, 94, 94, 4, 2, 12, 12, 15, 15, 5, 2, 11, 12, 15, 15, 34, 34, 2, 481, 2, 3, 3, 2, 2, 2, 2, 5, 3, 2, 2, 2, 2, 7, 3, 2, 2, 2, 2, 9, 3, 2, 2, 2, 2, 11, 3, 2, 2, 2, 2, 13, 3, 2, 2, 2, 2, 15, 3, 2, 2, 2, 2, 17, 3, 2, 2, 2, 2, 19, 3, 2, 2, 2, 2, 21, 3, 2, 2, 2, 2, 23, 3, 2, 2, 2, 2, 25, 3, 2, 2, 2, 2, 27, 3, 2, 2, 2, 2, 29, 3, 2, 2, 2, 2, 31, 3, 2, 2, 2, 2, 33, 3, 2, 2, 2, 2, 35, 3, 2, 2, 2, 2, 37, 3, 2, 2, 2, 2, 39, 3, 2, 2, 2, 2, 41, 3, 2, 2, 2, 2, 43, 3, 2, 2, 2, 2, 45, 3, 2, 2, 2, 2, 47, 3, 2, 2, 2, 2, 49, 3, 2, 2, 2, 2, 51, 3, 2, 2, 2, 2, 53, 3, 2, 2, 2, 2, 55, 3, 2, 2, 2, 2, 57, 3, 2, 2, 2, 2, 59, 3, 2, 2, 2, 2, 61, 3, 2, 2, 2, 2, 63, 3, 2, 2, 2, 2, 65, 3, 2, 2, 2, 2, 67, 3, 2, 2, 2, 2, 69, 3, 2, 2, 2, 2, 71, 3, 2, 2, 2, 2, 73, 3, 2, 2, 2, 2, 75, 3, 2, 2, 2, 2, 77, 3, 2, 2, 2, 2, 79, 3, 2, 2, 2, 2, 81, 3, 2, 2, 2, 2, 83, 3, 2, 2, 2, 2, 85, 3, 2, 2, 2, 2, 87, 3, 2, 2, 2, 2, 89, 3, 2, 2, 2, 2, 91, 3, 2, 2, 2, 2, 93, 3, 2, 2, 2, 2, 95, 3, 2, 2, 2, 2, 97, 3, 2, 2, 2, 2, 99, 3, 2, 2, 2, 2, 153, 3, 2, 2, 2, 2, 155, 3, 2, 2, 2, 2, 157, 3, 2, 2, 2, 2, 159, 3, 2, 2, 2, 2, 161, 3, 2, 2, 2, 2, 163, 3, 2, 2, 2, 2, 165, 3, 2, 2, 2, 2, 167, 3, 2, 2, 2, 3, 169, 3, 2, 2, 2, 5, 171, 3, 2, 2, 2, 7, 173, 3, 2, 2, 2, 9, 175, 3, 2, 2, 2, 11, 179, 3, 2, 2, 2, 13, 181, 3, 2, 2, 2, 15, 184, 3, 2, 2, 2, 17, 187, 3, 2, 2, 2, 19, 190, 3, 2, 2, 2, 21, 192, 3, 2, 2, 2, 23, 194, 3, 2, 2, 2, 25, 196, 3, 2, 2, 2, 27, 199, 3, 2, 2, 2, 29, 201, 3, 2, 2, 2, 31, 203, 3, 2, 2, 2, 33, 206, 3, 2, 2, 2, 35, 209, 3, 2, 2, 2, 37, 211, 3, 2, 2, 2, 39, 213, 3, 2, 2, 2, 41, 216, 3, 2, 2, 2, 43, 219, 3, 2, 2, 2, 45, 223, 3, 2, 2, 2, 47, 228, 3, 2, 2, 2, 49, 232, 3, 2, 2, 2, 51, 235, 3, 2, 2, 2, 53, 244, 3, 2, 2, 2, 55, 251, 3, 2, 2, 2, 57, 256, 3, 2, 2, 2, 59, 259, 3, 2, 2, 2, 61, 263, 3, 2, 2, 2, 63, 266, 3, 2, 2, 2, 65, 269, 3, 2, 2, 2, 67, 274, 3, 2, 2, 2, 69, 279, 3, 2, 2, 2, 71, 284, 3, 2, 2, 2, 73, 289, 3, 2, 2, 2, 75, 293, 3, 2, 2, 2, 77, 298, 3, 2, 2, 2, 79, 303, 3, 2, 2, 2, 81, 313, 3, 2, 2, 2, 83, 319, 3, 2, 2, 2, 85, 322, 3, 2, 2, 2, 87, 331, 3, 2, 2, 2, 89, 339, 3, 2, 2, 2, 91, 344, 3, 2, 2, 2, 93, 353, 3, 2, 2, 2, 95, 366, 3, 2, 2, 2, 97, 377, 3, 2, 2, 2, 99, 380, 3, 2, 2, 2, 101, 385, 3, 2, 2, 2, 103, 387, 3, 2, 2, 2, 105, 389, 3, 2, 2, 2, 107, 391, 3, 2, 2, 2, 109, 393, 3, 2, 2, 2, 111, 395, 3, 2, 2, 2, 113, 397, 3, 2, 2, 2, 115, 399, 3, 2, 2, 2, 117, 401, 3, 2, 2, 2, 119, 403, 3, 2, 2, 2, 121, 405, 3, 2, 2, 2, 123, 407, 3, 2, 2, 2, 125, 409, 3, 2, 2, 2, 127, 411, 3, 2, 2, 2, 129, 413, 3, 2, 2, 2, 131, 415, 3, 2, 2, 2, 133, 417, 3, 2, 2, 2, 135, 419, 3, 2, 2, 2, 137, 421, 3, 2, 2, 2, 139, 423, 3, 2, 2, 2, 141, 425, 3, 2, 2, 2, 143, 427, 3, 2, 2, 2, 145, 429, 3, 2, 2, 2, 147, 431, 3, 2, 2, 2, 149, 433, 3, 2, 2, 2, 151, 435, 3, 2, 2, 2, 153, 437, 3, 2, 2, 2, 155, 445, 3, 2, 2, 2, 157, 453, 3, 2, 2, 2, 159, 458, 3, 2, 2, 2, 161, 468, 3, 2, 2, 2, 163, 479, 3, 2, 2, 2, 165, 491, 3, 2, 2, 2, 167, 497, 3, 2, 2, 2, 169, 170, 7, 46, 2, 2, 170, 4, 3, 2, 2, 2, 171, 172, 7, 42, 2, 2, 172, 6, 3, 2, 2, 2, 173, 174, 7, 43, 2, 2, 174, 8, 3, 2, 2, 2, 175, 176, 7, 110, 2, 2, 176, 177, 7, 103, 2, 2, 177, 178, 7, 118, 2, 2, 178, 10, 3, 2, 2, 2, 179, 180, 7, 63, 2, 2, 180, 12, 3, 2, 2, 2, 181, 182, 7, 126, 2, 2, 182, 183, 7, 64, 2, 2, 183, 14, 3, 2, 2, 2, 184, 185, 7, 104, 2, 2, 185, 186, 7, 112, 2, 2, 186, 16, 3, 2, 2, 2, 187, 188, 7, 63, 2, 2, 188, 189, 7, 64, 2, 2, 189, 18, 3, 2, 2, 2, 190, 191, 7, 48, 2, 2, 191, 20, 3, 2, 2, 2, 192, 193, 7, 45, 2, 2, 193, 22, 3, 2, 2, 2, 194, 195, 7, 47, 2, 2, 195, 24, 3, 2, 2, 2, 196, 197, 7, 126, 2, 2, 197, 198, 7, 126, 2, 2, 198, 26, 3, 2, 2, 2, 199, 200, 7, 49, 2, 2, 200, 28, 3, 2, 2, 2, 201, 202, 7, 39, 2, 2, 202, 30, 3, 2, 2, 2, 203, 204, 7, 35, 2, 2, 204, 205, 7, 63, 2, 2, 205, 32, 3, 2, 2, 2, 206, 207, 7, 62, 2, 2, 207, 208, 7, 64, 2, 2, 208, 34, 3, 2, 2, 2, 209, 210, 7, 62, 2, 2, 210, 36, 3, 2, 2, 2, 211, 212, 7, 64, 2, 2, 212, 38, 3, 2, 2, 2, 213, 214, 7, 62, 2, 2, 214, 215, 7, 63, 2, 2, 215, 40, 3, 2, 2, 2, 216, 217, 7, 64, 2, 2, 217, 218, 7, 63, 2, 2, 218, 42, 3, 2, 2, 2, 219, 220, 5, 101, 51, 2, 220, 221, 5, 137, 69, 2, 221, 222, 5, 105, 53, 2, 222, 44, 3, 2, 2, 2, 223, 224, 5, 107, 54, 2, 224, 225, 5, 109, 55, 2, 225, 226, 5, 137, 69, 2, 226, 227, 5, 105, 53, 2, 227, 46, 3, 2, 2, 2, 228, 229, 5, 101, 51, 2, 229, 230, 5, 127, 64, 2, 230, 231, 5, 107, 54, 2, 231, 48, 3, 2, 2, 2, 232, 233, 5, 129, 65, 2, 233, 234, 5, 135, 68, 2, 234, 50, 3, 2, 2, 2, 235, 236, 5, 107, 54, 2, 236, 237, 5, 117, 59, 2, 237, 238, 5, 137, 69, 2, 238, 239, 5, 139, 70, 2, 239, 240, 5, 117, 59, 2, 240, 241, 5, 127, 64, 2, 241, 242, 5, 105, 53, 2, 242, 243, 5, 139, 70, 2, 243, 52, 3, 2, 2, 2, 244, 245, 5, 109, 55, 2, 245, 246, 5, 147, 74, 2, 246, 247, 5, 117, 59, 2, 247, 248, 5, 137, 69, 2, 248, 249, 5, 139, 70, 2, 249, 250, 5, 137, 69, 2, 250, 54, 3, 2, 2, 2, 251, 252, 5, 127, 64, 2, 252, 253, 5, 141, 71, 2, 253, 254, 5, 123, 62, 2, 254, 255, 5, 123, 62, 2, 255, 56, 3, 2, 2, 2, 256, 257, 5, 117, 59, 2, 257, 258, 5, 137, 69, 2, 258, 58, 3, 2, 2, 2, 259, 260, 5, 127, 64, 2, 260, 261, 5, 129, 65, 2, 261, 262, 5, 139, 70, 2, 262, 60, 3, 2, 2, 2, 263, 264, 5, 117, 59, 2, 264, 265, 5, 127, 64, 2, 265, 62, 3, 2, 2, 2, 266, 267, 5, 101, 51, 2, 267, 268, 5, 137, 69, 2, 268, 64, 3, 2, 2, 2, 269, 270, 5, 105, 53, 2, 270, 271, 5, 101, 51, 2, 271, 272, 5, 137, 69, 2, 272, 273, 5, 109, 55, 2, 273, 66, 3, 2, 2, 2, 274, 275, 5, 145, 73, 2, 275, 276, 5, 115, 58, 2, 276, 277, 5, 109, 55, 2, 277, 278, 5, 127, 64, 2, 278, 68, 3, 2, 2, 2, 279, 280, 5, 139, 70, 2, 280, 281, 5, 115, 58, 2, 281, 282, 5, 109, 55, 2, 282, 283, 5, 127, 64, 2, 283, 70, 3, 2, 2, 2, 284, 285, 5, 109, 55, 2, 285, 286, 5, 123, 62, 2, 286, 287, 5, 137, 69, 2, 287, 288, 5, 109, 55, 2, 288, 72, 3, 2, 2, 2, 289, 290, 5, 109, 55, 2, 290, 291, 5, 127, 64, 2, 291, 292, 5, 107, 54, 2, 292, 74, 3, 2, 2, 2, 293, 294, 5, 145, 73, 2, 294, 295, 5, 117, 59, 2, 295, 296, 5, 139, 70, 2, 296, 297, 5, 115, 58, 2, 297, 76, 3, 2, 2, 2, 298, 299, 5, 129, 65, 2, 299, 300, 5, 143, 72, 2, 300, 301, 5, 109, 55, 2, 301, 302, 5, 135, 68, 2, 302, 78, 3, 2, 2, 2, 303, 304, 5, 131, 66, 2, 304, 305, 5, 101, 51, 2, 305, 306, 5, 135, 68, 2, 306, 307, 5, 139, 70, 2, 307, 308, 5, 117, 59, 2, 308, 309, 5, 139, 70, 2, 309, 310, 5, 117, 59, 2, 310, 311, 5, 129, 65, 2, 311, 312, 5, 127, 64, 2, 312, 80, 3, 2, 2, 2, 313, 314, 5, 129, 65, 2, 314, 315, 5, 135, 68, 2, 315, 316, 5, 107, 54, 2, 316, 317, 5, 109, 55, 2, 317, 318, 5, 135, 68, 2, 318, 82, 3, 2, 2, 2, 319, 320, 5, 103, 52, 2, 320, 321, 5, 149, 75, 2, 321, 84, 3, 2, 2, 2, 322, 323, 5, 105, 53, 2, 323, 324, 5, 129, 65, 2, 324, 325, 5, 101, 51, 2, 325, 326, 5, 123, 62, 2, 326, 327, 5, 109, 55, 2, 327, 328, 5, 137, 69, 2, 328, 329, 5, 105, 53, 2, 329, 330, 5, 109, 55, 2, 330, 86, 3, 2, 2, 2, 331, 332, 5, 109, 55, 2, 332, 333, 5, 147, 74, 2, 333, 334, 5, 139, 70, 2, 334, 335, 5, 135, 68, 2, 335, 336, 5, 101, 51, 2, 336, 337, 5, 105, 53, 2, 337, 338, 5, 139, 70, 2, 338, 88, 3, 2, 2, 2, 339, 340, 5, 111, 56, 2, 340, 341, 5, 135, 68, 2, 341, 342, 5, 129, 65, 2, 342, 343, 5, 125, 63, 2, 343, 90, 3, 2, 2, 2, 344, 345, 5, 117, 59, 2, 345, 346, 5, 127, 64, 2, 346, 347, 5, 139, 70, 2, 347, 348, 5, 109, 55, 2, 348, 349, 5, 135, 68, 2, 349, 350, 5, 143, 72, 2, 350, 351, 5, 101, 51, 2, 351, 352, 5, 123, 62, 2, 352, 92, 3, 2, 2, 2, 353, 354, 5, 105, 53, 2, 354, 355, 5, 141, 71, 2, 355, 356, 5, 135, 68, 2, 356, 357, 5, 135, 68, 2, 357, 358, 5, 109, 55, 2, 358, 359, 5, 127, 64, 2, 359, 360, 5, 139, 70, 2, 360, 361, 7, 97, 2, 2, 361, 362, 5, 107, 54, 2, 362, 363, 5, 101, 51, 2, 363, 364, 5, 139, 70, 2, 364, 365, 5, 109, 55, 2, 365, 94, 3, 2, 2, 2, 366, 367, 5, 107, 54, 2, 367, 368, 5, 101, 51, 2, 368, 369, 5, 139, 70, 2, 369, 370, 5, 109, 55, 2, 370, 371, 7, 97, 2, 2, 371, 372, 5, 139, 70, 2, 372, 373, 5, 135, 68, 2, 373, 374, 5, 141, 71, 2, 374, 375, 5, 127, 64, 2, 375, 376, 5, 105, 53, 2, 376, 96, 3, 2, 2, 2, 377, 378, 5, 129, 65, 2, 378, 379, 5, 127, 64, 2, 379, 98, 3, 2, 2, 2, 380, 381, 5, 123, 62, 2, 381, 382, 5, 117, 59, 2, 382, 383, 5, 121, 61, 2, 383, 384, 5, 109, 55, 2, 384, 100, 3, 2, 2, 2, 385, 386, 9, 2, 2, 2, 386, 102, 3, 2, 2, 2, 387, 388, 9, 3, 2, 2, 388, 104, 3, 2, 2, 2, 389, 390, 9, 4, 2, 2, 390, 106, 3, 2, 2, 2, 391, 392, 9, 5, 2, 2, 392, 108, 3, 2, 2, 2, 393, 394, 9, 6, 2, 2, 394, 110, 3, 2, 2, 2, 395, 396, 9, 7, 2, 2, 396, 112, 3, 2, 2, 2, 397, 398, 9, 8, 2, 2, 398, 114, 3, 2, 2, 2, 399, 400, 9, 9, 2, 2, 400, 116, 3, 2, 2, 2, 401, 402, 9, 10, 2, 2, 402, 118, 3, 2, 2, 2, 403, 404, 9, 11, 2, 2, 404, 120, 3, 2, 2, 2, 405, 406, 9, 12, 2, 2, 406, 122, 3, 2, 2, 2, 407, 408, 9, 13, 2, 2, 408, 124, 3, 2, 2, 2, 409, 410, 9, 14, 2, 2, 410, 126, 3, 2, 2, 2, 411, 412, 9, 15, 2, 2, 412, 128, 3, 2, 2, 2, 413, 414, 9, 16, 2, 2, 414, 130, 3, 2, 2, 2, 415, 416, 9, 17, 2, 2, 416, 132, 3, 2, 2, 2, 417, 418, 9, 18, 2, 2, 418, 134, 3, 2, 2, 2, 419, 420, 9, 19, 2, 2, 420, 136, 3, 2, 2, 2, 421, 422, 9, 20, 2, 2, 422, 138, 3, 2, 2, 2, 423, 424, 9, 21, 2, 2, 424, 140, 3, 2, 2, 2, 425, 426, 9, 22, 2, 2, 426, 142, 3, 2, 2, 2, 427, 428, 9, 23, 2, 2, 428, 144, 3, 2, 2, 2, 429, 430, 9, 24, 2, 2, 430, 146, 3, 2, 2, 2, 431, 432, 9, 25, 2, 2, 432, 148, 3, 2, 2, 2, 433, 434, 9, 26, 2, 2, 434, 150, 3, 2, 2, 2, 435, 436, 9, 27, 2, 2, 436, 152, 3, 2, 2, 2, 437, 438, 7, 66, 2, 2, 438, 442, 9, 28, 2, 2, 439, 441, 9, 29, 2, 2, 440, 439, 3, 2, 2, 2, 441, 444, 3, 2, 2, 2, 442, 440, 3, 2, 2, 2, 442, 443, 3, 2, 2, 2, 443, 154, 3, 2, 2, 2, 444, 442, 3, 2, 2, 2, 445, 449, 9, 28, 2, 2, 446, 448, 9, 29, 2, 2, 447, 446, 3, 2, 2, 2, 448, 451, 3, 2, 2, 2, 449, 447, 3, 2, 2, 2, 449, 450, 3, 2, 2, 2, 450, 156, 3, 2, 2, 2, 451, 449, 3, 2, 2, 2, 452, 454, 9, 30, 2, 2, 453, 452, 3, 2, 2, 2, 454, 455, 3, 2, 2, 2, 455, 453, 3, 2, 2, 2, 455, 456, 3, 2, 2, 2, 456, 158, 3, 2, 2, 2, 457, 459, 9, 30, 2, 2, 458, 457, 3, 2, 2, 2, 459, 460, 3, 2, 2, 2, 460, 458, 3, 2, 2, 2, 460, 461, 3, 2, 2, 2, 461, 462, 3, 2, 2, 2, 462, 464, 7, 48, 2, 2, 463, 465, 9, 30, 2, 2, 464, 463, 3, 2, 2, 2, 465, 466, 3, 2, 2, 2, 466, 464, 3, 2, 2, 2, 466, 467, 3, 2, 2, 2, 467, 160, 3, 2, 2, 2, 468, 474, 7, 41, 2, 2, 469, 473, 10, 31, 2, 2, 470, 471, 7, 94, 2, 2, 471, 473, 11, 2, 2, 2, 472, 469, 3, 2, 2, 2, 472, 470, 3, 2, 2, 2, 473, 476, 3, 2, 2, 2, 474, 472, 3, 2, 2, 2, 474, 475, 3, 2, 2, 2, 475, 477, 3, 2, 2, 2, 476, 474, 3, 2, 2, 2, 477, 478, 7, 41, 2, 2, 478, 162, 3, 2, 2, 2, 479, 480, 7, 47, 2, 2, 480, 481, 7, 47, 2, 2, 481, 485, 3, 2, 2, 2, 482, 484, 10, 32, 2, 2, 483, 482, 3, 2, 2, 2, 484, 487, 3, 2, 2, 2, 485, 483, 3, 2, 2, 2, 485, 486, 3, 2, 2, 2, 486, 488, 3, 2, 2, 2, 487, 485, 3, 2, 2, 2, 488, 489, 8, 82, 2, 2, 489, 164, 3, 2, 2, 2, 490, 492, 9, 33, 2, 2, 491, 490, 3, 2, 2, 2, 492, 493, 3, 2, 2, 2, 493, 491, 3, 2, 2, 2, 493, 494, 3, 2, 2, 2, 494, 495, 3, 2, 2, 2, 495, 496, 8, 83, 2, 2, 496, 166, 3, 2, 2, 2, 497, 498, 7, 44, 2, 2, 498, 168, 3, 2, 2, 2, 12, 2, 442, 449, 455, 460, 466, 472, 474, 485, 493, 3, 8, 2, 2] \ No newline at end of file diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.tokens b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.tokens index fc5287d4..2c5313b1 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.tokens +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/LqlLexer.tokens @@ -55,12 +55,12 @@ STRING=54 COMMENT=55 WS=56 ASTERISK=57 -'let'=1 -'='=2 -'|>'=3 -'('=4 -')'=5 -','=6 +','=1 +'('=2 +')'=3 +'let'=4 +'='=5 +'|>'=6 'fn'=7 '=>'=8 '.'=9 diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllexer.rs b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllexer.rs index 95ec116d..5b55e7cd 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllexer.rs +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllexer.rs @@ -177,12 +177,12 @@ pub const ruleNames: [&'static str; 83] = [ pub const _LITERAL_NAMES: [Option<&'static str>; 58] = [ None, + Some("','"), + Some("'('"), + Some("')'"), Some("'let'"), Some("'='"), Some("'|>'"), - Some("'('"), - Some("')'"), - Some("','"), Some("'fn'"), Some("'=>'"), Some("'.'"), @@ -456,8 +456,8 @@ const _serializedATN: &'static str = \x48\x04\x49\x09\x49\x04\x4a\x09\x4a\x04\x4b\x09\x4b\x04\x4c\x09\x4c\x04\ \x4d\x09\x4d\x04\x4e\x09\x4e\x04\x4f\x09\x4f\x04\x50\x09\x50\x04\x51\x09\ \x51\x04\x52\x09\x52\x04\x53\x09\x53\x04\x54\x09\x54\x03\x02\x03\x02\x03\ - \x02\x03\x02\x03\x03\x03\x03\x03\x04\x03\x04\x03\x04\x03\x05\x03\x05\x03\ - \x06\x03\x06\x03\x07\x03\x07\x03\x08\x03\x08\x03\x08\x03\x09\x03\x09\x03\ + \x03\x03\x03\x03\x04\x03\x04\x03\x05\x03\x05\x03\x05\x03\x05\x03\x06\x03\ + \x06\x03\x07\x03\x07\x03\x07\x03\x08\x03\x08\x03\x08\x03\x09\x03\x09\x03\ \x09\x03\x0a\x03\x0a\x03\x0b\x03\x0b\x03\x0c\x03\x0c\x03\x0d\x03\x0d\x03\ \x0d\x03\x0e\x03\x0e\x03\x0f\x03\x0f\x03\x10\x03\x10\x03\x10\x03\x11\x03\ \x11\x03\x11\x03\x12\x03\x12\x03\x13\x03\x13\x03\x14\x03\x14\x03\x14\x03\ @@ -535,8 +535,8 @@ const _serializedATN: &'static str = \x02\x02\u{9b}\x03\x02\x02\x02\x02\u{9d}\x03\x02\x02\x02\x02\u{9f}\x03\ \x02\x02\x02\x02\u{a1}\x03\x02\x02\x02\x02\u{a3}\x03\x02\x02\x02\x02\u{a5}\ \x03\x02\x02\x02\x02\u{a7}\x03\x02\x02\x02\x03\u{a9}\x03\x02\x02\x02\x05\ - \u{ad}\x03\x02\x02\x02\x07\u{af}\x03\x02\x02\x02\x09\u{b2}\x03\x02\x02\ - \x02\x0b\u{b4}\x03\x02\x02\x02\x0d\u{b6}\x03\x02\x02\x02\x0f\u{b8}\x03\ + \u{ab}\x03\x02\x02\x02\x07\u{ad}\x03\x02\x02\x02\x09\u{af}\x03\x02\x02\ + \x02\x0b\u{b3}\x03\x02\x02\x02\x0d\u{b5}\x03\x02\x02\x02\x0f\u{b8}\x03\ \x02\x02\x02\x11\u{bb}\x03\x02\x02\x02\x13\u{be}\x03\x02\x02\x02\x15\u{c0}\ \x03\x02\x02\x02\x17\u{c2}\x03\x02\x02\x02\x19\u{c4}\x03\x02\x02\x02\x1b\ \u{c7}\x03\x02\x02\x02\x1d\u{c9}\x03\x02\x02\x02\x1f\u{cb}\x03\x02\x02\ @@ -566,12 +566,12 @@ const _serializedATN: &'static str = \x02\x02\u{9b}\u{1bd}\x03\x02\x02\x02\u{9d}\u{1c5}\x03\x02\x02\x02\u{9f}\ \u{1ca}\x03\x02\x02\x02\u{a1}\u{1d4}\x03\x02\x02\x02\u{a3}\u{1df}\x03\x02\ \x02\x02\u{a5}\u{1eb}\x03\x02\x02\x02\u{a7}\u{1f1}\x03\x02\x02\x02\u{a9}\ - \u{aa}\x07\x6e\x02\x02\u{aa}\u{ab}\x07\x67\x02\x02\u{ab}\u{ac}\x07\x76\ - \x02\x02\u{ac}\x04\x03\x02\x02\x02\u{ad}\u{ae}\x07\x3f\x02\x02\u{ae}\x06\ - \x03\x02\x02\x02\u{af}\u{b0}\x07\x7e\x02\x02\u{b0}\u{b1}\x07\x40\x02\x02\ - \u{b1}\x08\x03\x02\x02\x02\u{b2}\u{b3}\x07\x2a\x02\x02\u{b3}\x0a\x03\x02\ - \x02\x02\u{b4}\u{b5}\x07\x2b\x02\x02\u{b5}\x0c\x03\x02\x02\x02\u{b6}\u{b7}\ - \x07\x2e\x02\x02\u{b7}\x0e\x03\x02\x02\x02\u{b8}\u{b9}\x07\x68\x02\x02\ + \u{aa}\x07\x2e\x02\x02\u{aa}\x04\x03\x02\x02\x02\u{ab}\u{ac}\x07\x2a\x02\ + \x02\u{ac}\x06\x03\x02\x02\x02\u{ad}\u{ae}\x07\x2b\x02\x02\u{ae}\x08\x03\ + \x02\x02\x02\u{af}\u{b0}\x07\x6e\x02\x02\u{b0}\u{b1}\x07\x67\x02\x02\u{b1}\ + \u{b2}\x07\x76\x02\x02\u{b2}\x0a\x03\x02\x02\x02\u{b3}\u{b4}\x07\x3f\x02\ + \x02\u{b4}\x0c\x03\x02\x02\x02\u{b5}\u{b6}\x07\x7e\x02\x02\u{b6}\u{b7}\ + \x07\x40\x02\x02\u{b7}\x0e\x03\x02\x02\x02\u{b8}\u{b9}\x07\x68\x02\x02\ \u{b9}\u{ba}\x07\x70\x02\x02\u{ba}\x10\x03\x02\x02\x02\u{bb}\u{bc}\x07\ \x3f\x02\x02\u{bc}\u{bd}\x07\x40\x02\x02\u{bd}\x12\x03\x02\x02\x02\u{be}\ \u{bf}\x07\x30\x02\x02\u{bf}\x14\x03\x02\x02\x02\u{c0}\u{c1}\x07\x2d\x02\ diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllistener.rs b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllistener.rs index a034a5ad..c087465f 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllistener.rs +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqllistener.rs @@ -1,5 +1,5 @@ -#![allow(nonstandard_style)] // Generated from /home/user/DataProvider/Lql/Lql/Parsing/Lql.g4 by ANTLR 4.8 +// Generated from Lql.g4 by ANTLR 4.8 use super::lqlparser::*; use antlr_rust::tree::ParseTreeListener; @@ -24,6 +24,26 @@ pub trait LqlListener<'input>: ParseTreeListener<'input, LqlParserContextType> { * @param ctx the parse tree */ fn exit_statement(&mut self, _ctx: &StatementContext<'input>) {} + /** + * Enter a parse tree produced by {@link LqlParser#withStmt}. + * @param ctx the parse tree + */ + fn enter_withStmt(&mut self, _ctx: &WithStmtContext<'input>) {} + /** + * Exit a parse tree produced by {@link LqlParser#withStmt}. + * @param ctx the parse tree + */ + fn exit_withStmt(&mut self, _ctx: &WithStmtContext<'input>) {} + /** + * Enter a parse tree produced by {@link LqlParser#cteDef}. + * @param ctx the parse tree + */ + fn enter_cteDef(&mut self, _ctx: &CteDefContext<'input>) {} + /** + * Exit a parse tree produced by {@link LqlParser#cteDef}. + * @param ctx the parse tree + */ + fn exit_cteDef(&mut self, _ctx: &CteDefContext<'input>) {} /** * Enter a parse tree produced by {@link LqlParser#letStmt}. * @param ctx the parse tree diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlparser.rs b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlparser.rs index e636f11c..9c5d4678 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlparser.rs +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlparser.rs @@ -98,39 +98,43 @@ pub const WS: isize = 56; pub const ASTERISK: isize = 57; pub const RULE_program: usize = 0; pub const RULE_statement: usize = 1; -pub const RULE_letStmt: usize = 2; -pub const RULE_pipeExpr: usize = 3; -pub const RULE_expr: usize = 4; -pub const RULE_windowSpec: usize = 5; -pub const RULE_partitionClause: usize = 6; -pub const RULE_orderClause: usize = 7; -pub const RULE_orderByArgList: usize = 8; -pub const RULE_orderByArg: usize = 9; -pub const RULE_lambdaExpr: usize = 10; -pub const RULE_qualifiedIdent: usize = 11; -pub const RULE_argList: usize = 12; -pub const RULE_arg: usize = 13; -pub const RULE_columnAlias: usize = 14; -pub const RULE_arithmeticExpr: usize = 15; -pub const RULE_arithmeticTerm: usize = 16; -pub const RULE_arithmeticFactor: usize = 17; -pub const RULE_functionCall: usize = 18; -pub const RULE_namedArg: usize = 19; -pub const RULE_logicalExpr: usize = 20; -pub const RULE_andExpr: usize = 21; -pub const RULE_atomicExpr: usize = 22; -pub const RULE_comparison: usize = 23; -pub const RULE_existsExpr: usize = 24; -pub const RULE_nullCheckExpr: usize = 25; -pub const RULE_inExpr: usize = 26; -pub const RULE_caseExpr: usize = 27; -pub const RULE_whenClause: usize = 28; -pub const RULE_caseResult: usize = 29; -pub const RULE_orderDirection: usize = 30; -pub const RULE_comparisonOp: usize = 31; -pub const ruleNames: [&'static str; 32] = [ +pub const RULE_withStmt: usize = 2; +pub const RULE_cteDef: usize = 3; +pub const RULE_letStmt: usize = 4; +pub const RULE_pipeExpr: usize = 5; +pub const RULE_expr: usize = 6; +pub const RULE_windowSpec: usize = 7; +pub const RULE_partitionClause: usize = 8; +pub const RULE_orderClause: usize = 9; +pub const RULE_orderByArgList: usize = 10; +pub const RULE_orderByArg: usize = 11; +pub const RULE_lambdaExpr: usize = 12; +pub const RULE_qualifiedIdent: usize = 13; +pub const RULE_argList: usize = 14; +pub const RULE_arg: usize = 15; +pub const RULE_columnAlias: usize = 16; +pub const RULE_arithmeticExpr: usize = 17; +pub const RULE_arithmeticTerm: usize = 18; +pub const RULE_arithmeticFactor: usize = 19; +pub const RULE_functionCall: usize = 20; +pub const RULE_namedArg: usize = 21; +pub const RULE_logicalExpr: usize = 22; +pub const RULE_andExpr: usize = 23; +pub const RULE_atomicExpr: usize = 24; +pub const RULE_comparison: usize = 25; +pub const RULE_existsExpr: usize = 26; +pub const RULE_nullCheckExpr: usize = 27; +pub const RULE_inExpr: usize = 28; +pub const RULE_caseExpr: usize = 29; +pub const RULE_whenClause: usize = 30; +pub const RULE_caseResult: usize = 31; +pub const RULE_orderDirection: usize = 32; +pub const RULE_comparisonOp: usize = 33; +pub const ruleNames: [&'static str; 34] = [ "program", "statement", + "withStmt", + "cteDef", "letStmt", "pipeExpr", "expr", @@ -165,12 +169,12 @@ pub const ruleNames: [&'static str; 32] = [ pub const _LITERAL_NAMES: [Option<&'static str>; 58] = [ None, + Some("','"), + Some("'('"), + Some("')'"), Some("'let'"), Some("'='"), Some("'|>'"), - Some("'('"), - Some("')'"), - Some("','"), Some("'fn'"), Some("'=>'"), Some("'.'"), @@ -556,15 +560,16 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(67); + recog.base.set_state(71); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); while (((_la) & !0x3f) == 0 - && ((1usize << _la) & ((1usize << T__0) | (1usize << T__3) | (1usize << T__6))) + && ((1usize << _la) & ((1usize << T__1) | (1usize << T__3) | (1usize << T__6))) != 0) || (((_la - 32) & !0x3f) == 0 && ((1usize << (_la - 32)) & ((1usize << (CASE - 32)) + | (1usize << (WITH - 32)) | (1usize << (PARAMETER - 32)) | (1usize << (IDENT - 32)) | (1usize << (INT - 32)) @@ -576,15 +581,15 @@ where { { /*InvokeRule statement*/ - recog.base.set_state(64); + recog.base.set_state(68); recog.statement()?; } } - recog.base.set_state(69); + recog.base.set_state(73); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); } - recog.base.set_state(70); + recog.base.set_state(74); recog.base.match_token(EOF, &mut recog.err_handler)?; } Ok(()) @@ -658,6 +663,12 @@ impl<'input> StatementContextExt<'input> { pub trait StatementContextAttrs<'input>: LqlParserContext<'input> + BorrowMut> { + fn withStmt(&self) -> Option>> + where + Self: Sized, + { + self.child_of_type(0) + } fn letStmt(&self) -> Option>> where Self: Sized, @@ -686,25 +697,35 @@ where recog.base.enter_rule(_localctx.clone(), 2, RULE_statement); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { - recog.base.set_state(74); + recog.base.set_state(79); recog.err_handler.sync(&mut recog.base)?; match recog.base.input.la(1) { - T__0 => { + WITH => { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - /*InvokeRule letStmt*/ - recog.base.set_state(72); - recog.letStmt()?; + /*InvokeRule withStmt*/ + recog.base.set_state(76); + recog.withStmt()?; } } - T__3 | T__6 | CASE | PARAMETER | IDENT | INT | DECIMAL | STRING | ASTERISK => { + T__3 => { //recog.base.enter_outer_alt(_localctx.clone(), 2); recog.base.enter_outer_alt(None, 2); + { + /*InvokeRule letStmt*/ + recog.base.set_state(77); + recog.letStmt()?; + } + } + + T__1 | T__6 | CASE | PARAMETER | IDENT | INT | DECIMAL | STRING | ASTERISK => { + //recog.base.enter_outer_alt(_localctx.clone(), 3); + recog.base.enter_outer_alt(None, 3); { /*InvokeRule pipeExpr*/ - recog.base.set_state(73); + recog.base.set_state(78); recog.pipeExpr()?; } } @@ -729,6 +750,280 @@ where Ok(_localctx) } } +//------------------- withStmt ---------------- +pub type WithStmtContextAll<'input> = WithStmtContext<'input>; + +pub type WithStmtContext<'input> = BaseParserRuleContext<'input, WithStmtContextExt<'input>>; + +#[derive(Clone)] +pub struct WithStmtContextExt<'input> { + ph: PhantomData<&'input str>, +} + +impl<'input> LqlParserContext<'input> for WithStmtContext<'input> {} + +impl<'input, 'a> Listenable + 'a> for WithStmtContext<'input> { + fn enter(&self, listener: &mut (dyn LqlListener<'input> + 'a)) { + listener.enter_every_rule(self); + listener.enter_withStmt(self); + } + fn exit(&self, listener: &mut (dyn LqlListener<'input> + 'a)) { + listener.exit_withStmt(self); + listener.exit_every_rule(self); + } +} + +impl<'input, 'a> Visitable + 'a> for WithStmtContext<'input> { + fn accept(&self, visitor: &mut (dyn LqlVisitor<'input> + 'a)) { + visitor.visit_withStmt(self); + } +} + +impl<'input> CustomRuleContext<'input> for WithStmtContextExt<'input> { + type TF = LocalTokenFactory<'input>; + type Ctx = LqlParserContextType; + fn get_rule_index(&self) -> usize { + RULE_withStmt + } + //fn type_rule_index() -> usize where Self: Sized { RULE_withStmt } +} +antlr_rust::tid! {WithStmtContextExt<'a>} + +impl<'input> WithStmtContextExt<'input> { + fn new( + parent: Option + 'input>>, + invoking_state: isize, + ) -> Rc> { + Rc::new(BaseParserRuleContext::new_parser_ctx( + parent, + invoking_state, + WithStmtContextExt { ph: PhantomData }, + )) + } +} + +pub trait WithStmtContextAttrs<'input>: + LqlParserContext<'input> + BorrowMut> +{ + /// Retrieves first TerminalNode corresponding to token WITH + /// Returns `None` if there is no child corresponding to token WITH + fn WITH(&self) -> Option>> + where + Self: Sized, + { + self.get_token(WITH, 0) + } + fn cteDef_all(&self) -> Vec>> + where + Self: Sized, + { + self.children_of_type() + } + fn cteDef(&self, i: usize) -> Option>> + where + Self: Sized, + { + self.child_of_type(i) + } + fn pipeExpr(&self) -> Option>> + where + Self: Sized, + { + self.child_of_type(0) + } +} + +impl<'input> WithStmtContextAttrs<'input> for WithStmtContext<'input> {} + +impl<'input, I, H> LqlParser<'input, I, H> +where + I: TokenStream<'input, TF = LocalTokenFactory<'input>> + TidAble<'input>, + H: ErrorStrategy<'input, BaseParserType<'input, I>>, +{ + pub fn withStmt(&mut self) -> Result>, ANTLRError> { + let mut recog = self; + let _parentctx = recog.ctx.take(); + let mut _localctx = WithStmtContextExt::new(_parentctx.clone(), recog.base.get_state()); + recog.base.enter_rule(_localctx.clone(), 4, RULE_withStmt); + let mut _localctx: Rc = _localctx; + let mut _la: isize = -1; + let result: Result<(), ANTLRError> = (|| { + //recog.base.enter_outer_alt(_localctx.clone(), 1); + recog.base.enter_outer_alt(None, 1); + { + recog.base.set_state(81); + recog.base.match_token(WITH, &mut recog.err_handler)?; + + /*InvokeRule cteDef*/ + recog.base.set_state(82); + recog.cteDef()?; + + recog.base.set_state(87); + recog.err_handler.sync(&mut recog.base)?; + _la = recog.base.input.la(1); + while _la == T__0 { + { + { + recog.base.set_state(83); + recog.base.match_token(T__0, &mut recog.err_handler)?; + + /*InvokeRule cteDef*/ + recog.base.set_state(84); + recog.cteDef()?; + } + } + recog.base.set_state(89); + recog.err_handler.sync(&mut recog.base)?; + _la = recog.base.input.la(1); + } + /*InvokeRule pipeExpr*/ + recog.base.set_state(90); + recog.pipeExpr()?; + } + Ok(()) + })(); + match result { + Ok(_) => {} + Err(e @ ANTLRError::FallThrough(_)) => return Err(e), + Err(ref re) => { + //_localctx.exception = re; + recog.err_handler.report_error(&mut recog.base, re); + recog.err_handler.recover(&mut recog.base, re)?; + } + } + recog.base.exit_rule(); + + Ok(_localctx) + } +} +//------------------- cteDef ---------------- +pub type CteDefContextAll<'input> = CteDefContext<'input>; + +pub type CteDefContext<'input> = BaseParserRuleContext<'input, CteDefContextExt<'input>>; + +#[derive(Clone)] +pub struct CteDefContextExt<'input> { + ph: PhantomData<&'input str>, +} + +impl<'input> LqlParserContext<'input> for CteDefContext<'input> {} + +impl<'input, 'a> Listenable + 'a> for CteDefContext<'input> { + fn enter(&self, listener: &mut (dyn LqlListener<'input> + 'a)) { + listener.enter_every_rule(self); + listener.enter_cteDef(self); + } + fn exit(&self, listener: &mut (dyn LqlListener<'input> + 'a)) { + listener.exit_cteDef(self); + listener.exit_every_rule(self); + } +} + +impl<'input, 'a> Visitable + 'a> for CteDefContext<'input> { + fn accept(&self, visitor: &mut (dyn LqlVisitor<'input> + 'a)) { + visitor.visit_cteDef(self); + } +} + +impl<'input> CustomRuleContext<'input> for CteDefContextExt<'input> { + type TF = LocalTokenFactory<'input>; + type Ctx = LqlParserContextType; + fn get_rule_index(&self) -> usize { + RULE_cteDef + } + //fn type_rule_index() -> usize where Self: Sized { RULE_cteDef } +} +antlr_rust::tid! {CteDefContextExt<'a>} + +impl<'input> CteDefContextExt<'input> { + fn new( + parent: Option + 'input>>, + invoking_state: isize, + ) -> Rc> { + Rc::new(BaseParserRuleContext::new_parser_ctx( + parent, + invoking_state, + CteDefContextExt { ph: PhantomData }, + )) + } +} + +pub trait CteDefContextAttrs<'input>: + LqlParserContext<'input> + BorrowMut> +{ + /// Retrieves first TerminalNode corresponding to token IDENT + /// Returns `None` if there is no child corresponding to token IDENT + fn IDENT(&self) -> Option>> + where + Self: Sized, + { + self.get_token(IDENT, 0) + } + /// Retrieves first TerminalNode corresponding to token AS + /// Returns `None` if there is no child corresponding to token AS + fn AS(&self) -> Option>> + where + Self: Sized, + { + self.get_token(AS, 0) + } + fn pipeExpr(&self) -> Option>> + where + Self: Sized, + { + self.child_of_type(0) + } +} + +impl<'input> CteDefContextAttrs<'input> for CteDefContext<'input> {} + +impl<'input, I, H> LqlParser<'input, I, H> +where + I: TokenStream<'input, TF = LocalTokenFactory<'input>> + TidAble<'input>, + H: ErrorStrategy<'input, BaseParserType<'input, I>>, +{ + pub fn cteDef(&mut self) -> Result>, ANTLRError> { + let mut recog = self; + let _parentctx = recog.ctx.take(); + let mut _localctx = CteDefContextExt::new(_parentctx.clone(), recog.base.get_state()); + recog.base.enter_rule(_localctx.clone(), 6, RULE_cteDef); + let mut _localctx: Rc = _localctx; + let result: Result<(), ANTLRError> = (|| { + //recog.base.enter_outer_alt(_localctx.clone(), 1); + recog.base.enter_outer_alt(None, 1); + { + recog.base.set_state(92); + recog.base.match_token(IDENT, &mut recog.err_handler)?; + + recog.base.set_state(93); + recog.base.match_token(AS, &mut recog.err_handler)?; + + recog.base.set_state(94); + recog.base.match_token(T__1, &mut recog.err_handler)?; + + /*InvokeRule pipeExpr*/ + recog.base.set_state(95); + recog.pipeExpr()?; + + recog.base.set_state(96); + recog.base.match_token(T__2, &mut recog.err_handler)?; + } + Ok(()) + })(); + match result { + Ok(_) => {} + Err(e @ ANTLRError::FallThrough(_)) => return Err(e), + Err(ref re) => { + //_localctx.exception = re; + recog.err_handler.report_error(&mut recog.base, re); + recog.err_handler.recover(&mut recog.base, re)?; + } + } + recog.base.exit_rule(); + + Ok(_localctx) + } +} //------------------- letStmt ---------------- pub type LetStmtContextAll<'input> = LetStmtContext<'input>; @@ -811,23 +1106,23 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = LetStmtContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 4, RULE_letStmt); + recog.base.enter_rule(_localctx.clone(), 8, RULE_letStmt); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(76); - recog.base.match_token(T__0, &mut recog.err_handler)?; + recog.base.set_state(98); + recog.base.match_token(T__3, &mut recog.err_handler)?; - recog.base.set_state(77); + recog.base.set_state(99); recog.base.match_token(IDENT, &mut recog.err_handler)?; - recog.base.set_state(78); - recog.base.match_token(T__1, &mut recog.err_handler)?; + recog.base.set_state(100); + recog.base.match_token(T__4, &mut recog.err_handler)?; /*InvokeRule pipeExpr*/ - recog.base.set_state(79); + recog.base.set_state(101); recog.pipeExpr()?; } Ok(()) @@ -926,7 +1221,7 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = PipeExprContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 6, RULE_pipeExpr); + recog.base.enter_rule(_localctx.clone(), 10, RULE_pipeExpr); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { @@ -934,24 +1229,24 @@ where recog.base.enter_outer_alt(None, 1); { /*InvokeRule expr*/ - recog.base.set_state(81); + recog.base.set_state(103); recog.expr()?; - recog.base.set_state(86); + recog.base.set_state(108); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); - while _la == T__2 { + while _la == T__5 { { { - recog.base.set_state(82); - recog.base.match_token(T__2, &mut recog.err_handler)?; + recog.base.set_state(104); + recog.base.match_token(T__5, &mut recog.err_handler)?; /*InvokeRule expr*/ - recog.base.set_state(83); + recog.base.set_state(105); recog.expr()?; } } - recog.base.set_state(88); + recog.base.set_state(110); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); } @@ -1132,29 +1427,29 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = ExprContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 8, RULE_expr); + recog.base.enter_rule(_localctx.clone(), 12, RULE_expr); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { - recog.base.set_state(119); + recog.base.set_state(141); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(5, &mut recog.base)? { + match recog.interpreter.adaptive_predict(6, &mut recog.base)? { 1 => { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(89); + recog.base.set_state(111); recog.base.match_token(IDENT, &mut recog.err_handler)?; - recog.base.set_state(90); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(112); + recog.base.match_token(T__1, &mut recog.err_handler)?; - recog.base.set_state(92); + recog.base.set_state(114); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if (((_la) & !0x3f) == 0 && ((1usize << _la) - & ((1usize << T__3) | (1usize << T__6) | (1usize << EXISTS))) + & ((1usize << T__1) | (1usize << T__6) | (1usize << EXISTS))) != 0) || (((_la - 32) & !0x3f) == 0 && ((1usize << (_la - 32)) @@ -1170,44 +1465,44 @@ where { { /*InvokeRule argList*/ - recog.base.set_state(91); + recog.base.set_state(113); recog.argList()?; } } - recog.base.set_state(94); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(116); + recog.base.match_token(T__2, &mut recog.err_handler)?; - recog.base.set_state(95); + recog.base.set_state(117); recog.base.match_token(OVER, &mut recog.err_handler)?; - recog.base.set_state(96); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(118); + recog.base.match_token(T__1, &mut recog.err_handler)?; /*InvokeRule windowSpec*/ - recog.base.set_state(97); + recog.base.set_state(119); recog.windowSpec()?; - recog.base.set_state(98); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(120); + recog.base.match_token(T__2, &mut recog.err_handler)?; } } 2 => { //recog.base.enter_outer_alt(_localctx.clone(), 2); recog.base.enter_outer_alt(None, 2); { - recog.base.set_state(100); + recog.base.set_state(122); recog.base.match_token(IDENT, &mut recog.err_handler)?; - recog.base.set_state(101); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(123); + recog.base.match_token(T__1, &mut recog.err_handler)?; - recog.base.set_state(103); + recog.base.set_state(125); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if (((_la) & !0x3f) == 0 && ((1usize << _la) - & ((1usize << T__3) | (1usize << T__6) | (1usize << EXISTS))) + & ((1usize << T__1) | (1usize << T__6) | (1usize << EXISTS))) != 0) || (((_la - 32) & !0x3f) == 0 && ((1usize << (_la - 32)) @@ -1223,20 +1518,20 @@ where { { /*InvokeRule argList*/ - recog.base.set_state(102); + recog.base.set_state(124); recog.argList()?; } } - recog.base.set_state(105); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(127); + recog.base.match_token(T__2, &mut recog.err_handler)?; } } 3 => { //recog.base.enter_outer_alt(_localctx.clone(), 3); recog.base.enter_outer_alt(None, 3); { - recog.base.set_state(106); + recog.base.set_state(128); recog.base.match_token(IDENT, &mut recog.err_handler)?; } } @@ -1244,15 +1539,15 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 4); recog.base.enter_outer_alt(None, 4); { - recog.base.set_state(107); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(129); + recog.base.match_token(T__1, &mut recog.err_handler)?; /*InvokeRule pipeExpr*/ - recog.base.set_state(108); + recog.base.set_state(130); recog.pipeExpr()?; - recog.base.set_state(109); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(131); + recog.base.match_token(T__2, &mut recog.err_handler)?; } } 5 => { @@ -1260,7 +1555,7 @@ where recog.base.enter_outer_alt(None, 5); { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(111); + recog.base.set_state(133); recog.qualifiedIdent()?; } } @@ -1269,7 +1564,7 @@ where recog.base.enter_outer_alt(None, 6); { /*InvokeRule lambdaExpr*/ - recog.base.set_state(112); + recog.base.set_state(134); recog.lambdaExpr()?; } } @@ -1278,7 +1573,7 @@ where recog.base.enter_outer_alt(None, 7); { /*InvokeRule caseExpr*/ - recog.base.set_state(113); + recog.base.set_state(135); recog.caseExpr()?; } } @@ -1286,7 +1581,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 8); recog.base.enter_outer_alt(None, 8); { - recog.base.set_state(114); + recog.base.set_state(136); recog.base.match_token(INT, &mut recog.err_handler)?; } } @@ -1294,7 +1589,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 9); recog.base.enter_outer_alt(None, 9); { - recog.base.set_state(115); + recog.base.set_state(137); recog.base.match_token(DECIMAL, &mut recog.err_handler)?; } } @@ -1302,7 +1597,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 10); recog.base.enter_outer_alt(None, 10); { - recog.base.set_state(116); + recog.base.set_state(138); recog.base.match_token(ASTERISK, &mut recog.err_handler)?; } } @@ -1310,7 +1605,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 11); recog.base.enter_outer_alt(None, 11); { - recog.base.set_state(117); + recog.base.set_state(139); recog.base.match_token(STRING, &mut recog.err_handler)?; } } @@ -1318,7 +1613,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 12); recog.base.enter_outer_alt(None, 12); { - recog.base.set_state(118); + recog.base.set_state(140); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } } @@ -1423,31 +1718,31 @@ where let mut _localctx = WindowSpecContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 10, RULE_windowSpec); + .enter_rule(_localctx.clone(), 14, RULE_windowSpec); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(122); + recog.base.set_state(144); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == PARTITION { { /*InvokeRule partitionClause*/ - recog.base.set_state(121); + recog.base.set_state(143); recog.partitionClause()?; } } - recog.base.set_state(125); + recog.base.set_state(147); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == ORDER { { /*InvokeRule orderClause*/ - recog.base.set_state(124); + recog.base.set_state(146); recog.orderClause()?; } } @@ -1562,20 +1857,20 @@ where PartitionClauseContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 12, RULE_partitionClause); + .enter_rule(_localctx.clone(), 16, RULE_partitionClause); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(127); + recog.base.set_state(149); recog.base.match_token(PARTITION, &mut recog.err_handler)?; - recog.base.set_state(128); + recog.base.set_state(150); recog.base.match_token(BY, &mut recog.err_handler)?; /*InvokeRule argList*/ - recog.base.set_state(129); + recog.base.set_state(151); recog.argList()?; } Ok(()) @@ -1686,20 +1981,20 @@ where let mut _localctx = OrderClauseContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 14, RULE_orderClause); + .enter_rule(_localctx.clone(), 18, RULE_orderClause); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(131); + recog.base.set_state(153); recog.base.match_token(ORDER, &mut recog.err_handler)?; - recog.base.set_state(132); + recog.base.set_state(154); recog.base.match_token(BY, &mut recog.err_handler)?; /*InvokeRule orderByArgList*/ - recog.base.set_state(133); + recog.base.set_state(155); recog.orderByArgList()?; } Ok(()) @@ -1802,7 +2097,7 @@ where OrderByArgListContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 16, RULE_orderByArgList); + .enter_rule(_localctx.clone(), 20, RULE_orderByArgList); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { @@ -1810,24 +2105,24 @@ where recog.base.enter_outer_alt(None, 1); { /*InvokeRule orderByArg*/ - recog.base.set_state(135); + recog.base.set_state(157); recog.orderByArg()?; - recog.base.set_state(140); + recog.base.set_state(162); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); - while _la == T__5 { + while _la == T__0 { { { - recog.base.set_state(136); - recog.base.match_token(T__5, &mut recog.err_handler)?; + recog.base.set_state(158); + recog.base.match_token(T__0, &mut recog.err_handler)?; /*InvokeRule orderByArg*/ - recog.base.set_state(137); + recog.base.set_state(159); recog.orderByArg()?; } } - recog.base.set_state(142); + recog.base.set_state(164); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); } @@ -1950,51 +2245,51 @@ where let mut _localctx = OrderByArgContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 18, RULE_orderByArg); + .enter_rule(_localctx.clone(), 22, RULE_orderByArg); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(147); + recog.base.set_state(169); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(9, &mut recog.base)? { + match recog.interpreter.adaptive_predict(10, &mut recog.base)? { 1 => { { /*InvokeRule arithmeticExpr*/ - recog.base.set_state(143); + recog.base.set_state(165); recog.arithmeticExpr()?; } } 2 => { { /*InvokeRule functionCall*/ - recog.base.set_state(144); + recog.base.set_state(166); recog.functionCall()?; } } 3 => { { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(145); + recog.base.set_state(167); recog.qualifiedIdent()?; } } 4 => { - recog.base.set_state(146); + recog.base.set_state(168); recog.base.match_token(IDENT, &mut recog.err_handler)?; } _ => {} } - recog.base.set_state(150); + recog.base.set_state(172); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == ASC || _la == DESC { { /*InvokeRule orderDirection*/ - recog.base.set_state(149); + recog.base.set_state(171); recog.orderDirection()?; } } @@ -2106,47 +2401,47 @@ where let mut _localctx = LambdaExprContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 20, RULE_lambdaExpr); + .enter_rule(_localctx.clone(), 24, RULE_lambdaExpr); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(152); + recog.base.set_state(174); recog.base.match_token(T__6, &mut recog.err_handler)?; - recog.base.set_state(153); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(175); + recog.base.match_token(T__1, &mut recog.err_handler)?; - recog.base.set_state(154); + recog.base.set_state(176); recog.base.match_token(IDENT, &mut recog.err_handler)?; - recog.base.set_state(159); + recog.base.set_state(181); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); - while _la == T__5 { + while _la == T__0 { { { - recog.base.set_state(155); - recog.base.match_token(T__5, &mut recog.err_handler)?; + recog.base.set_state(177); + recog.base.match_token(T__0, &mut recog.err_handler)?; - recog.base.set_state(156); + recog.base.set_state(178); recog.base.match_token(IDENT, &mut recog.err_handler)?; } } - recog.base.set_state(161); + recog.base.set_state(183); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); } - recog.base.set_state(162); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(184); + recog.base.match_token(T__2, &mut recog.err_handler)?; - recog.base.set_state(163); + recog.base.set_state(185); recog.base.match_token(T__7, &mut recog.err_handler)?; /*InvokeRule logicalExpr*/ - recog.base.set_state(164); + recog.base.set_state(186); recog.logicalExpr()?; } Ok(()) @@ -2252,30 +2547,30 @@ where QualifiedIdentContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 22, RULE_qualifiedIdent); + .enter_rule(_localctx.clone(), 26, RULE_qualifiedIdent); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(166); + recog.base.set_state(188); recog.base.match_token(IDENT, &mut recog.err_handler)?; - recog.base.set_state(169); + recog.base.set_state(191); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); loop { { { - recog.base.set_state(167); + recog.base.set_state(189); recog.base.match_token(T__8, &mut recog.err_handler)?; - recog.base.set_state(168); + recog.base.set_state(190); recog.base.match_token(IDENT, &mut recog.err_handler)?; } } - recog.base.set_state(171); + recog.base.set_state(193); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if !(_la == T__8) { @@ -2379,7 +2674,7 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = ArgListContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 24, RULE_argList); + recog.base.enter_rule(_localctx.clone(), 28, RULE_argList); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { @@ -2387,24 +2682,24 @@ where recog.base.enter_outer_alt(None, 1); { /*InvokeRule arg*/ - recog.base.set_state(173); + recog.base.set_state(195); recog.arg()?; - recog.base.set_state(178); + recog.base.set_state(200); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); - while _la == T__5 { + while _la == T__0 { { { - recog.base.set_state(174); - recog.base.match_token(T__5, &mut recog.err_handler)?; + recog.base.set_state(196); + recog.base.match_token(T__0, &mut recog.err_handler)?; /*InvokeRule arg*/ - recog.base.set_state(175); + recog.base.set_state(197); recog.arg()?; } } - recog.base.set_state(180); + recog.base.set_state(202); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); } @@ -2547,18 +2842,18 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = ArgContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 26, RULE_arg); + recog.base.enter_rule(_localctx.clone(), 30, RULE_arg); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { - recog.base.set_state(194); + recog.base.set_state(216); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(14, &mut recog.base)? { + match recog.interpreter.adaptive_predict(15, &mut recog.base)? { 1 => { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { /*InvokeRule columnAlias*/ - recog.base.set_state(181); + recog.base.set_state(203); recog.columnAlias()?; } } @@ -2567,7 +2862,7 @@ where recog.base.enter_outer_alt(None, 2); { /*InvokeRule arithmeticExpr*/ - recog.base.set_state(182); + recog.base.set_state(204); recog.arithmeticExpr()?; } } @@ -2576,7 +2871,7 @@ where recog.base.enter_outer_alt(None, 3); { /*InvokeRule functionCall*/ - recog.base.set_state(183); + recog.base.set_state(205); recog.functionCall()?; } } @@ -2585,7 +2880,7 @@ where recog.base.enter_outer_alt(None, 4); { /*InvokeRule caseExpr*/ - recog.base.set_state(184); + recog.base.set_state(206); recog.caseExpr()?; } } @@ -2594,7 +2889,7 @@ where recog.base.enter_outer_alt(None, 5); { /*InvokeRule expr*/ - recog.base.set_state(185); + recog.base.set_state(207); recog.expr()?; } } @@ -2603,7 +2898,7 @@ where recog.base.enter_outer_alt(None, 6); { /*InvokeRule namedArg*/ - recog.base.set_state(186); + recog.base.set_state(208); recog.namedArg()?; } } @@ -2612,7 +2907,7 @@ where recog.base.enter_outer_alt(None, 7); { /*InvokeRule comparison*/ - recog.base.set_state(187); + recog.base.set_state(209); recog.comparison()?; } } @@ -2621,7 +2916,7 @@ where recog.base.enter_outer_alt(None, 8); { /*InvokeRule pipeExpr*/ - recog.base.set_state(188); + recog.base.set_state(210); recog.pipeExpr()?; } } @@ -2630,7 +2925,7 @@ where recog.base.enter_outer_alt(None, 9); { /*InvokeRule lambdaExpr*/ - recog.base.set_state(189); + recog.base.set_state(211); recog.lambdaExpr()?; } } @@ -2638,15 +2933,15 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 10); recog.base.enter_outer_alt(None, 10); { - recog.base.set_state(190); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(212); + recog.base.match_token(T__1, &mut recog.err_handler)?; /*InvokeRule pipeExpr*/ - recog.base.set_state(191); + recog.base.set_state(213); recog.pipeExpr()?; - recog.base.set_state(192); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(214); + recog.base.match_token(T__2, &mut recog.err_handler)?; } } @@ -2779,53 +3074,53 @@ where let mut _localctx = ColumnAliasContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 28, RULE_columnAlias); + .enter_rule(_localctx.clone(), 32, RULE_columnAlias); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(200); + recog.base.set_state(222); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(15, &mut recog.base)? { + match recog.interpreter.adaptive_predict(16, &mut recog.base)? { 1 => { { /*InvokeRule arithmeticExpr*/ - recog.base.set_state(196); + recog.base.set_state(218); recog.arithmeticExpr()?; } } 2 => { { /*InvokeRule functionCall*/ - recog.base.set_state(197); + recog.base.set_state(219); recog.functionCall()?; } } 3 => { { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(198); + recog.base.set_state(220); recog.qualifiedIdent()?; } } 4 => { - recog.base.set_state(199); + recog.base.set_state(221); recog.base.match_token(IDENT, &mut recog.err_handler)?; } _ => {} } - recog.base.set_state(204); + recog.base.set_state(226); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == AS { { - recog.base.set_state(202); + recog.base.set_state(224); recog.base.match_token(AS, &mut recog.err_handler)?; - recog.base.set_state(203); + recog.base.set_state(225); recog.base.match_token(IDENT, &mut recog.err_handler)?; } } @@ -2930,7 +3225,7 @@ where ArithmeticExprContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 30, RULE_arithmeticExpr); + .enter_rule(_localctx.clone(), 34, RULE_arithmeticExpr); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { @@ -2938,20 +3233,20 @@ where recog.base.enter_outer_alt(None, 1); { /*InvokeRule arithmeticTerm*/ - recog.base.set_state(206); + recog.base.set_state(228); recog.arithmeticTerm()?; - recog.base.set_state(211); + recog.base.set_state(233); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); - while ((_la) & !0x3f) == 0 + while (((_la) & !0x3f) == 0 && ((1usize << _la) & ((1usize << T__9) | (1usize << T__10) | (1usize << T__11))) - != 0 + != 0) { { { - recog.base.set_state(207); + recog.base.set_state(229); _la = recog.base.input.la(1); if { !(((_la) & !0x3f) == 0 @@ -2970,11 +3265,11 @@ where recog.base.consume(&mut recog.err_handler); } /*InvokeRule arithmeticTerm*/ - recog.base.set_state(208); + recog.base.set_state(230); recog.arithmeticTerm()?; } } - recog.base.set_state(213); + recog.base.set_state(235); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); } @@ -3094,7 +3389,7 @@ where ArithmeticTermContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 32, RULE_arithmeticTerm); + .enter_rule(_localctx.clone(), 36, RULE_arithmeticTerm); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { @@ -3103,17 +3398,17 @@ where recog.base.enter_outer_alt(None, 1); { /*InvokeRule arithmeticFactor*/ - recog.base.set_state(214); + recog.base.set_state(236); recog.arithmeticFactor()?; - recog.base.set_state(219); + recog.base.set_state(241); recog.err_handler.sync(&mut recog.base)?; - _alt = recog.interpreter.adaptive_predict(18, &mut recog.base)?; + _alt = recog.interpreter.adaptive_predict(19, &mut recog.base)?; while { _alt != 2 && _alt != INVALID_ALT } { if _alt == 1 { { { - recog.base.set_state(215); + recog.base.set_state(237); _la = recog.base.input.la(1); if { !(_la == T__12 || _la == T__13 || _la == ASTERISK) } { recog.err_handler.recover_inline(&mut recog.base)?; @@ -3125,14 +3420,14 @@ where recog.base.consume(&mut recog.err_handler); } /*InvokeRule arithmeticFactor*/ - recog.base.set_state(216); + recog.base.set_state(238); recog.arithmeticFactor()?; } } } - recog.base.set_state(221); + recog.base.set_state(243); recog.err_handler.sync(&mut recog.base)?; - _alt = recog.interpreter.adaptive_predict(18, &mut recog.base)?; + _alt = recog.interpreter.adaptive_predict(19, &mut recog.base)?; } } Ok(()) @@ -3289,18 +3584,18 @@ where ArithmeticFactorContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 34, RULE_arithmeticFactor); + .enter_rule(_localctx.clone(), 38, RULE_arithmeticFactor); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { - recog.base.set_state(234); + recog.base.set_state(256); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(19, &mut recog.base)? { + match recog.interpreter.adaptive_predict(20, &mut recog.base)? { 1 => { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(222); + recog.base.set_state(244); recog.qualifiedIdent()?; } } @@ -3308,7 +3603,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 2); recog.base.enter_outer_alt(None, 2); { - recog.base.set_state(223); + recog.base.set_state(245); recog.base.match_token(IDENT, &mut recog.err_handler)?; } } @@ -3316,7 +3611,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 3); recog.base.enter_outer_alt(None, 3); { - recog.base.set_state(224); + recog.base.set_state(246); recog.base.match_token(INT, &mut recog.err_handler)?; } } @@ -3324,7 +3619,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 4); recog.base.enter_outer_alt(None, 4); { - recog.base.set_state(225); + recog.base.set_state(247); recog.base.match_token(DECIMAL, &mut recog.err_handler)?; } } @@ -3332,7 +3627,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 5); recog.base.enter_outer_alt(None, 5); { - recog.base.set_state(226); + recog.base.set_state(248); recog.base.match_token(STRING, &mut recog.err_handler)?; } } @@ -3341,7 +3636,7 @@ where recog.base.enter_outer_alt(None, 6); { /*InvokeRule functionCall*/ - recog.base.set_state(227); + recog.base.set_state(249); recog.functionCall()?; } } @@ -3350,7 +3645,7 @@ where recog.base.enter_outer_alt(None, 7); { /*InvokeRule caseExpr*/ - recog.base.set_state(228); + recog.base.set_state(250); recog.caseExpr()?; } } @@ -3358,7 +3653,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 8); recog.base.enter_outer_alt(None, 8); { - recog.base.set_state(229); + recog.base.set_state(251); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } } @@ -3366,15 +3661,15 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 9); recog.base.enter_outer_alt(None, 9); { - recog.base.set_state(230); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(252); + recog.base.match_token(T__1, &mut recog.err_handler)?; /*InvokeRule arithmeticExpr*/ - recog.base.set_state(231); + recog.base.set_state(253); recog.arithmeticExpr()?; - recog.base.set_state(232); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(254); + recog.base.match_token(T__2, &mut recog.err_handler)?; } } @@ -3503,25 +3798,25 @@ where let mut _localctx = FunctionCallContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 36, RULE_functionCall); + .enter_rule(_localctx.clone(), 40, RULE_functionCall); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(236); + recog.base.set_state(258); recog.base.match_token(IDENT, &mut recog.err_handler)?; - recog.base.set_state(237); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(259); + recog.base.match_token(T__1, &mut recog.err_handler)?; - recog.base.set_state(242); + recog.base.set_state(264); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if (((_la) & !0x3f) == 0 && ((1usize << _la) - & ((1usize << T__3) + & ((1usize << T__1) | (1usize << T__6) | (1usize << DISTINCT) | (1usize << EXISTS))) @@ -3539,42 +3834,42 @@ where != 0) { { - recog.base.set_state(239); + recog.base.set_state(261); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == DISTINCT { { - recog.base.set_state(238); + recog.base.set_state(260); recog.base.match_token(DISTINCT, &mut recog.err_handler)?; } } /*InvokeRule argList*/ - recog.base.set_state(241); + recog.base.set_state(263); recog.argList()?; } } - recog.base.set_state(244); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(266); + recog.base.match_token(T__2, &mut recog.err_handler)?; - recog.base.set_state(250); + recog.base.set_state(272); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == OVER { { - recog.base.set_state(245); + recog.base.set_state(267); recog.base.match_token(OVER, &mut recog.err_handler)?; - recog.base.set_state(246); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(268); + recog.base.match_token(T__1, &mut recog.err_handler)?; /*InvokeRule windowSpec*/ - recog.base.set_state(247); + recog.base.set_state(269); recog.windowSpec()?; - recog.base.set_state(248); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(270); + recog.base.match_token(T__2, &mut recog.err_handler)?; } } } @@ -3690,14 +3985,14 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = NamedArgContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 38, RULE_namedArg); + recog.base.enter_rule(_localctx.clone(), 42, RULE_namedArg); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(252); + recog.base.set_state(274); _la = recog.base.input.la(1); if { !(_la == ON || _la == IDENT) } { recog.err_handler.recover_inline(&mut recog.base)?; @@ -3708,23 +4003,23 @@ where recog.err_handler.report_match(&mut recog.base); recog.base.consume(&mut recog.err_handler); } - recog.base.set_state(253); - recog.base.match_token(T__1, &mut recog.err_handler)?; + recog.base.set_state(275); + recog.base.match_token(T__4, &mut recog.err_handler)?; - recog.base.set_state(256); + recog.base.set_state(278); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(23, &mut recog.base)? { + match recog.interpreter.adaptive_predict(24, &mut recog.base)? { 1 => { { /*InvokeRule comparison*/ - recog.base.set_state(254); + recog.base.set_state(276); recog.comparison()?; } } 2 => { { /*InvokeRule logicalExpr*/ - recog.base.set_state(255); + recog.base.set_state(277); recog.logicalExpr()?; } } @@ -3845,7 +4140,7 @@ where let mut _localctx = LogicalExprContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 40, RULE_logicalExpr); + .enter_rule(_localctx.clone(), 44, RULE_logicalExpr); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { let mut _alt: isize; @@ -3853,28 +4148,28 @@ where recog.base.enter_outer_alt(None, 1); { /*InvokeRule andExpr*/ - recog.base.set_state(258); + recog.base.set_state(280); recog.andExpr()?; - recog.base.set_state(263); + recog.base.set_state(285); recog.err_handler.sync(&mut recog.base)?; - _alt = recog.interpreter.adaptive_predict(24, &mut recog.base)?; + _alt = recog.interpreter.adaptive_predict(25, &mut recog.base)?; while { _alt != 2 && _alt != INVALID_ALT } { if _alt == 1 { { { - recog.base.set_state(259); + recog.base.set_state(281); recog.base.match_token(OR, &mut recog.err_handler)?; /*InvokeRule andExpr*/ - recog.base.set_state(260); + recog.base.set_state(282); recog.andExpr()?; } } } - recog.base.set_state(265); + recog.base.set_state(287); recog.err_handler.sync(&mut recog.base)?; - _alt = recog.interpreter.adaptive_predict(24, &mut recog.base)?; + _alt = recog.interpreter.adaptive_predict(25, &mut recog.base)?; } } Ok(()) @@ -3988,7 +4283,7 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = AndExprContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 42, RULE_andExpr); + recog.base.enter_rule(_localctx.clone(), 46, RULE_andExpr); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { let mut _alt: isize; @@ -3996,28 +4291,28 @@ where recog.base.enter_outer_alt(None, 1); { /*InvokeRule atomicExpr*/ - recog.base.set_state(266); + recog.base.set_state(288); recog.atomicExpr()?; - recog.base.set_state(271); + recog.base.set_state(293); recog.err_handler.sync(&mut recog.base)?; - _alt = recog.interpreter.adaptive_predict(25, &mut recog.base)?; + _alt = recog.interpreter.adaptive_predict(26, &mut recog.base)?; while { _alt != 2 && _alt != INVALID_ALT } { if _alt == 1 { { { - recog.base.set_state(267); + recog.base.set_state(289); recog.base.match_token(AND, &mut recog.err_handler)?; /*InvokeRule atomicExpr*/ - recog.base.set_state(268); + recog.base.set_state(290); recog.atomicExpr()?; } } } - recog.base.set_state(273); + recog.base.set_state(295); recog.err_handler.sync(&mut recog.base)?; - _alt = recog.interpreter.adaptive_predict(25, &mut recog.base)?; + _alt = recog.interpreter.adaptive_predict(26, &mut recog.base)?; } } Ok(()) @@ -4118,18 +4413,18 @@ where let mut _localctx = AtomicExprContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 44, RULE_atomicExpr); + .enter_rule(_localctx.clone(), 48, RULE_atomicExpr); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { - recog.base.set_state(279); + recog.base.set_state(301); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(26, &mut recog.base)? { + match recog.interpreter.adaptive_predict(27, &mut recog.base)? { 1 => { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { /*InvokeRule comparison*/ - recog.base.set_state(274); + recog.base.set_state(296); recog.comparison()?; } } @@ -4137,15 +4432,15 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 2); recog.base.enter_outer_alt(None, 2); { - recog.base.set_state(275); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(297); + recog.base.match_token(T__1, &mut recog.err_handler)?; /*InvokeRule logicalExpr*/ - recog.base.set_state(276); + recog.base.set_state(298); recog.logicalExpr()?; - recog.base.set_state(277); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(299); + recog.base.match_token(T__2, &mut recog.err_handler)?; } } @@ -4351,27 +4646,27 @@ where let mut _localctx = ComparisonContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 46, RULE_comparison); + .enter_rule(_localctx.clone(), 50, RULE_comparison); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { - recog.base.set_state(334); + recog.base.set_state(356); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(33, &mut recog.base)? { + match recog.interpreter.adaptive_predict(34, &mut recog.base)? { 1 => { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { /*InvokeRule arithmeticExpr*/ - recog.base.set_state(281); + recog.base.set_state(303); recog.arithmeticExpr()?; /*InvokeRule comparisonOp*/ - recog.base.set_state(282); + recog.base.set_state(304); recog.comparisonOp()?; /*InvokeRule arithmeticExpr*/ - recog.base.set_state(283); + recog.base.set_state(305); recog.arithmeticExpr()?; } } @@ -4380,41 +4675,41 @@ where recog.base.enter_outer_alt(None, 2); { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(285); + recog.base.set_state(307); recog.qualifiedIdent()?; /*InvokeRule comparisonOp*/ - recog.base.set_state(286); + recog.base.set_state(308); recog.comparisonOp()?; - recog.base.set_state(293); + recog.base.set_state(315); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(27, &mut recog.base)? { + match recog.interpreter.adaptive_predict(28, &mut recog.base)? { 1 => { { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(287); + recog.base.set_state(309); recog.qualifiedIdent()?; } } 2 => { - recog.base.set_state(288); + recog.base.set_state(310); recog.base.match_token(STRING, &mut recog.err_handler)?; } 3 => { - recog.base.set_state(289); + recog.base.set_state(311); recog.base.match_token(IDENT, &mut recog.err_handler)?; } 4 => { - recog.base.set_state(290); + recog.base.set_state(312); recog.base.match_token(INT, &mut recog.err_handler)?; } 5 => { - recog.base.set_state(291); + recog.base.set_state(313); recog.base.match_token(DECIMAL, &mut recog.err_handler)?; } 6 => { - recog.base.set_state(292); + recog.base.set_state(314); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } @@ -4426,41 +4721,41 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 3); recog.base.enter_outer_alt(None, 3); { - recog.base.set_state(295); + recog.base.set_state(317); recog.base.match_token(IDENT, &mut recog.err_handler)?; /*InvokeRule comparisonOp*/ - recog.base.set_state(296); + recog.base.set_state(318); recog.comparisonOp()?; - recog.base.set_state(303); + recog.base.set_state(325); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(28, &mut recog.base)? { + match recog.interpreter.adaptive_predict(29, &mut recog.base)? { 1 => { { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(297); + recog.base.set_state(319); recog.qualifiedIdent()?; } } 2 => { - recog.base.set_state(298); + recog.base.set_state(320); recog.base.match_token(STRING, &mut recog.err_handler)?; } 3 => { - recog.base.set_state(299); + recog.base.set_state(321); recog.base.match_token(IDENT, &mut recog.err_handler)?; } 4 => { - recog.base.set_state(300); + recog.base.set_state(322); recog.base.match_token(INT, &mut recog.err_handler)?; } 5 => { - recog.base.set_state(301); + recog.base.set_state(323); recog.base.match_token(DECIMAL, &mut recog.err_handler)?; } 6 => { - recog.base.set_state(302); + recog.base.set_state(324); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } @@ -4472,41 +4767,41 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 4); recog.base.enter_outer_alt(None, 4); { - recog.base.set_state(305); + recog.base.set_state(327); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; /*InvokeRule comparisonOp*/ - recog.base.set_state(306); + recog.base.set_state(328); recog.comparisonOp()?; - recog.base.set_state(313); + recog.base.set_state(335); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(29, &mut recog.base)? { + match recog.interpreter.adaptive_predict(30, &mut recog.base)? { 1 => { { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(307); + recog.base.set_state(329); recog.qualifiedIdent()?; } } 2 => { - recog.base.set_state(308); + recog.base.set_state(330); recog.base.match_token(STRING, &mut recog.err_handler)?; } 3 => { - recog.base.set_state(309); + recog.base.set_state(331); recog.base.match_token(IDENT, &mut recog.err_handler)?; } 4 => { - recog.base.set_state(310); + recog.base.set_state(332); recog.base.match_token(INT, &mut recog.err_handler)?; } 5 => { - recog.base.set_state(311); + recog.base.set_state(333); recog.base.match_token(DECIMAL, &mut recog.err_handler)?; } 6 => { - recog.base.set_state(312); + recog.base.set_state(334); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } @@ -4519,16 +4814,16 @@ where recog.base.enter_outer_alt(None, 5); { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(315); + recog.base.set_state(337); recog.qualifiedIdent()?; - recog.base.set_state(317); + recog.base.set_state(339); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == ASC || _la == DESC { { /*InvokeRule orderDirection*/ - recog.base.set_state(316); + recog.base.set_state(338); recog.orderDirection()?; } } @@ -4538,16 +4833,16 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 6); recog.base.enter_outer_alt(None, 6); { - recog.base.set_state(319); + recog.base.set_state(341); recog.base.match_token(IDENT, &mut recog.err_handler)?; - recog.base.set_state(321); + recog.base.set_state(343); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == ASC || _la == DESC { { /*InvokeRule orderDirection*/ - recog.base.set_state(320); + recog.base.set_state(342); recog.orderDirection()?; } } @@ -4557,16 +4852,16 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 7); recog.base.enter_outer_alt(None, 7); { - recog.base.set_state(323); + recog.base.set_state(345); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; - recog.base.set_state(325); + recog.base.set_state(347); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == ASC || _la == DESC { { /*InvokeRule orderDirection*/ - recog.base.set_state(324); + recog.base.set_state(346); recog.orderDirection()?; } } @@ -4576,7 +4871,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 8); recog.base.enter_outer_alt(None, 8); { - recog.base.set_state(327); + recog.base.set_state(349); recog.base.match_token(STRING, &mut recog.err_handler)?; } } @@ -4584,7 +4879,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 9); recog.base.enter_outer_alt(None, 9); { - recog.base.set_state(328); + recog.base.set_state(350); recog.base.match_token(INT, &mut recog.err_handler)?; } } @@ -4592,7 +4887,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 10); recog.base.enter_outer_alt(None, 10); { - recog.base.set_state(329); + recog.base.set_state(351); recog.base.match_token(DECIMAL, &mut recog.err_handler)?; } } @@ -4601,7 +4896,7 @@ where recog.base.enter_outer_alt(None, 11); { /*InvokeRule expr*/ - recog.base.set_state(330); + recog.base.set_state(352); recog.expr()?; } } @@ -4610,7 +4905,7 @@ where recog.base.enter_outer_alt(None, 12); { /*InvokeRule existsExpr*/ - recog.base.set_state(331); + recog.base.set_state(353); recog.existsExpr()?; } } @@ -4619,7 +4914,7 @@ where recog.base.enter_outer_alt(None, 13); { /*InvokeRule nullCheckExpr*/ - recog.base.set_state(332); + recog.base.set_state(354); recog.nullCheckExpr()?; } } @@ -4628,7 +4923,7 @@ where recog.base.enter_outer_alt(None, 14); { /*InvokeRule inExpr*/ - recog.base.set_state(333); + recog.base.set_state(355); recog.inExpr()?; } } @@ -4735,24 +5030,24 @@ where let mut _localctx = ExistsExprContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 48, RULE_existsExpr); + .enter_rule(_localctx.clone(), 52, RULE_existsExpr); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(336); + recog.base.set_state(358); recog.base.match_token(EXISTS, &mut recog.err_handler)?; - recog.base.set_state(337); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(359); + recog.base.match_token(T__1, &mut recog.err_handler)?; /*InvokeRule pipeExpr*/ - recog.base.set_state(338); + recog.base.set_state(360); recog.pipeExpr()?; - recog.base.set_state(339); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(361); + recog.base.match_token(T__2, &mut recog.err_handler)?; } Ok(()) })(); @@ -4888,49 +5183,49 @@ where NullCheckExprContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 50, RULE_nullCheckExpr); + .enter_rule(_localctx.clone(), 54, RULE_nullCheckExpr); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(344); + recog.base.set_state(366); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(34, &mut recog.base)? { + match recog.interpreter.adaptive_predict(35, &mut recog.base)? { 1 => { { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(341); + recog.base.set_state(363); recog.qualifiedIdent()?; } } 2 => { - recog.base.set_state(342); + recog.base.set_state(364); recog.base.match_token(IDENT, &mut recog.err_handler)?; } 3 => { - recog.base.set_state(343); + recog.base.set_state(365); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } _ => {} } { - recog.base.set_state(346); + recog.base.set_state(368); recog.base.match_token(IS, &mut recog.err_handler)?; - recog.base.set_state(348); + recog.base.set_state(370); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == NOT { { - recog.base.set_state(347); + recog.base.set_state(369); recog.base.match_token(NOT, &mut recog.err_handler)?; } } - recog.base.set_state(350); + recog.base.set_state(372); recog.base.match_token(NULL, &mut recog.err_handler)?; } } @@ -5060,61 +5355,61 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = InExprContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 52, RULE_inExpr); + recog.base.enter_rule(_localctx.clone(), 56, RULE_inExpr); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(355); + recog.base.set_state(377); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(36, &mut recog.base)? { + match recog.interpreter.adaptive_predict(37, &mut recog.base)? { 1 => { { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(352); + recog.base.set_state(374); recog.qualifiedIdent()?; } } 2 => { - recog.base.set_state(353); + recog.base.set_state(375); recog.base.match_token(IDENT, &mut recog.err_handler)?; } 3 => { - recog.base.set_state(354); + recog.base.set_state(376); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } _ => {} } - recog.base.set_state(357); + recog.base.set_state(379); recog.base.match_token(IN, &mut recog.err_handler)?; - recog.base.set_state(358); - recog.base.match_token(T__3, &mut recog.err_handler)?; + recog.base.set_state(380); + recog.base.match_token(T__1, &mut recog.err_handler)?; - recog.base.set_state(361); + recog.base.set_state(383); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(37, &mut recog.base)? { + match recog.interpreter.adaptive_predict(38, &mut recog.base)? { 1 => { { /*InvokeRule pipeExpr*/ - recog.base.set_state(359); + recog.base.set_state(381); recog.pipeExpr()?; } } 2 => { { /*InvokeRule argList*/ - recog.base.set_state(360); + recog.base.set_state(382); recog.argList()?; } } _ => {} } - recog.base.set_state(363); - recog.base.match_token(T__4, &mut recog.err_handler)?; + recog.base.set_state(385); + recog.base.match_token(T__2, &mut recog.err_handler)?; } Ok(()) })(); @@ -5242,49 +5537,49 @@ where let mut recog = self; let _parentctx = recog.ctx.take(); let mut _localctx = CaseExprContextExt::new(_parentctx.clone(), recog.base.get_state()); - recog.base.enter_rule(_localctx.clone(), 54, RULE_caseExpr); + recog.base.enter_rule(_localctx.clone(), 58, RULE_caseExpr); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(365); + recog.base.set_state(387); recog.base.match_token(CASE, &mut recog.err_handler)?; - recog.base.set_state(367); + recog.base.set_state(389); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); loop { { { /*InvokeRule whenClause*/ - recog.base.set_state(366); + recog.base.set_state(388); recog.whenClause()?; } } - recog.base.set_state(369); + recog.base.set_state(391); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if !(_la == WHEN) { break; } } - recog.base.set_state(373); + recog.base.set_state(395); recog.err_handler.sync(&mut recog.base)?; _la = recog.base.input.la(1); if _la == ELSE { { - recog.base.set_state(371); + recog.base.set_state(393); recog.base.match_token(ELSE, &mut recog.err_handler)?; /*InvokeRule caseResult*/ - recog.base.set_state(372); + recog.base.set_state(394); recog.caseResult()?; } } - recog.base.set_state(375); + recog.base.set_state(397); recog.base.match_token(END, &mut recog.err_handler)?; } Ok(()) @@ -5401,24 +5696,24 @@ where let mut _localctx = WhenClauseContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 56, RULE_whenClause); + .enter_rule(_localctx.clone(), 60, RULE_whenClause); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(377); + recog.base.set_state(399); recog.base.match_token(WHEN, &mut recog.err_handler)?; /*InvokeRule comparison*/ - recog.base.set_state(378); + recog.base.set_state(400); recog.comparison()?; - recog.base.set_state(379); + recog.base.set_state(401); recog.base.match_token(THEN, &mut recog.err_handler)?; /*InvokeRule caseResult*/ - recog.base.set_state(380); + recog.base.set_state(402); recog.caseResult()?; } Ok(()) @@ -5565,18 +5860,18 @@ where let mut _localctx = CaseResultContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 58, RULE_caseResult); + .enter_rule(_localctx.clone(), 62, RULE_caseResult); let mut _localctx: Rc = _localctx; let result: Result<(), ANTLRError> = (|| { - recog.base.set_state(390); + recog.base.set_state(412); recog.err_handler.sync(&mut recog.base)?; - match recog.interpreter.adaptive_predict(40, &mut recog.base)? { + match recog.interpreter.adaptive_predict(41, &mut recog.base)? { 1 => { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { /*InvokeRule arithmeticExpr*/ - recog.base.set_state(382); + recog.base.set_state(404); recog.arithmeticExpr()?; } } @@ -5585,7 +5880,7 @@ where recog.base.enter_outer_alt(None, 2); { /*InvokeRule comparison*/ - recog.base.set_state(383); + recog.base.set_state(405); recog.comparison()?; } } @@ -5594,7 +5889,7 @@ where recog.base.enter_outer_alt(None, 3); { /*InvokeRule qualifiedIdent*/ - recog.base.set_state(384); + recog.base.set_state(406); recog.qualifiedIdent()?; } } @@ -5602,7 +5897,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 4); recog.base.enter_outer_alt(None, 4); { - recog.base.set_state(385); + recog.base.set_state(407); recog.base.match_token(IDENT, &mut recog.err_handler)?; } } @@ -5610,7 +5905,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 5); recog.base.enter_outer_alt(None, 5); { - recog.base.set_state(386); + recog.base.set_state(408); recog.base.match_token(INT, &mut recog.err_handler)?; } } @@ -5618,7 +5913,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 6); recog.base.enter_outer_alt(None, 6); { - recog.base.set_state(387); + recog.base.set_state(409); recog.base.match_token(DECIMAL, &mut recog.err_handler)?; } } @@ -5626,7 +5921,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 7); recog.base.enter_outer_alt(None, 7); { - recog.base.set_state(388); + recog.base.set_state(410); recog.base.match_token(STRING, &mut recog.err_handler)?; } } @@ -5634,7 +5929,7 @@ where //recog.base.enter_outer_alt(_localctx.clone(), 8); recog.base.enter_outer_alt(None, 8); { - recog.base.set_state(389); + recog.base.set_state(411); recog.base.match_token(PARAMETER, &mut recog.err_handler)?; } } @@ -5745,14 +6040,14 @@ where OrderDirectionContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 60, RULE_orderDirection); + .enter_rule(_localctx.clone(), 64, RULE_orderDirection); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(392); + recog.base.set_state(414); _la = recog.base.input.la(1); if { !(_la == ASC || _la == DESC) } { recog.err_handler.recover_inline(&mut recog.base)?; @@ -5859,19 +6154,19 @@ where let mut _localctx = ComparisonOpContextExt::new(_parentctx.clone(), recog.base.get_state()); recog .base - .enter_rule(_localctx.clone(), 62, RULE_comparisonOp); + .enter_rule(_localctx.clone(), 66, RULE_comparisonOp); let mut _localctx: Rc = _localctx; let mut _la: isize = -1; let result: Result<(), ANTLRError> = (|| { //recog.base.enter_outer_alt(_localctx.clone(), 1); recog.base.enter_outer_alt(None, 1); { - recog.base.set_state(394); + recog.base.set_state(416); _la = recog.base.input.la(1); if { !((((_la) & !0x3f) == 0 && ((1usize << _la) - & ((1usize << T__1) + & ((1usize << T__4) | (1usize << T__14) | (1usize << T__15) | (1usize << T__16) @@ -5922,238 +6217,251 @@ lazy_static! { const _serializedATN: &'static str = "\x03\u{608b}\u{a72a}\u{8133}\u{b9ed}\u{417c}\u{3be7}\u{7786}\u{5964}\x03\ - \x3b\u{18f}\x04\x02\x09\x02\x04\x03\x09\x03\x04\x04\x09\x04\x04\x05\x09\ + \x3b\u{1a5}\x04\x02\x09\x02\x04\x03\x09\x03\x04\x04\x09\x04\x04\x05\x09\ \x05\x04\x06\x09\x06\x04\x07\x09\x07\x04\x08\x09\x08\x04\x09\x09\x09\x04\ \x0a\x09\x0a\x04\x0b\x09\x0b\x04\x0c\x09\x0c\x04\x0d\x09\x0d\x04\x0e\x09\ \x0e\x04\x0f\x09\x0f\x04\x10\x09\x10\x04\x11\x09\x11\x04\x12\x09\x12\x04\ \x13\x09\x13\x04\x14\x09\x14\x04\x15\x09\x15\x04\x16\x09\x16\x04\x17\x09\ \x17\x04\x18\x09\x18\x04\x19\x09\x19\x04\x1a\x09\x1a\x04\x1b\x09\x1b\x04\ \x1c\x09\x1c\x04\x1d\x09\x1d\x04\x1e\x09\x1e\x04\x1f\x09\x1f\x04\x20\x09\ - \x20\x04\x21\x09\x21\x03\x02\x07\x02\x44\x0a\x02\x0c\x02\x0e\x02\x47\x0b\ - \x02\x03\x02\x03\x02\x03\x03\x03\x03\x05\x03\x4d\x0a\x03\x03\x04\x03\x04\ - \x03\x04\x03\x04\x03\x04\x03\x05\x03\x05\x03\x05\x07\x05\x57\x0a\x05\x0c\ - \x05\x0e\x05\x5a\x0b\x05\x03\x06\x03\x06\x03\x06\x05\x06\x5f\x0a\x06\x03\ - \x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x05\ - \x06\x6a\x0a\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\ - \x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x05\x06\x7a\x0a\ - \x06\x03\x07\x05\x07\x7d\x0a\x07\x03\x07\x05\x07\u{80}\x0a\x07\x03\x08\x03\ - \x08\x03\x08\x03\x08\x03\x09\x03\x09\x03\x09\x03\x09\x03\x0a\x03\x0a\x03\ - \x0a\x07\x0a\u{8d}\x0a\x0a\x0c\x0a\x0e\x0a\u{90}\x0b\x0a\x03\x0b\x03\x0b\ - \x03\x0b\x03\x0b\x05\x0b\u{96}\x0a\x0b\x03\x0b\x05\x0b\u{99}\x0a\x0b\x03\ - \x0c\x03\x0c\x03\x0c\x03\x0c\x03\x0c\x07\x0c\u{a0}\x0a\x0c\x0c\x0c\x0e\x0c\ - \u{a3}\x0b\x0c\x03\x0c\x03\x0c\x03\x0c\x03\x0c\x03\x0d\x03\x0d\x03\x0d\x06\ - \x0d\u{ac}\x0a\x0d\x0d\x0d\x0e\x0d\u{ad}\x03\x0e\x03\x0e\x03\x0e\x07\x0e\ - \u{b3}\x0a\x0e\x0c\x0e\x0e\x0e\u{b6}\x0b\x0e\x03\x0f\x03\x0f\x03\x0f\x03\ - \x0f\x03\x0f\x03\x0f\x03\x0f\x03\x0f\x03\x0f\x03\x0f\x03\x0f\x03\x0f\x03\ - \x0f\x05\x0f\u{c5}\x0a\x0f\x03\x10\x03\x10\x03\x10\x03\x10\x05\x10\u{cb}\ - \x0a\x10\x03\x10\x03\x10\x05\x10\u{cf}\x0a\x10\x03\x11\x03\x11\x03\x11\x07\ - \x11\u{d4}\x0a\x11\x0c\x11\x0e\x11\u{d7}\x0b\x11\x03\x12\x03\x12\x03\x12\ - \x07\x12\u{dc}\x0a\x12\x0c\x12\x0e\x12\u{df}\x0b\x12\x03\x13\x03\x13\x03\ - \x13\x03\x13\x03\x13\x03\x13\x03\x13\x03\x13\x03\x13\x03\x13\x03\x13\x03\ - \x13\x05\x13\u{ed}\x0a\x13\x03\x14\x03\x14\x03\x14\x05\x14\u{f2}\x0a\x14\ - \x03\x14\x05\x14\u{f5}\x0a\x14\x03\x14\x03\x14\x03\x14\x03\x14\x03\x14\x03\ - \x14\x05\x14\u{fd}\x0a\x14\x03\x15\x03\x15\x03\x15\x03\x15\x05\x15\u{103}\ - \x0a\x15\x03\x16\x03\x16\x03\x16\x07\x16\u{108}\x0a\x16\x0c\x16\x0e\x16\ - \u{10b}\x0b\x16\x03\x17\x03\x17\x03\x17\x07\x17\u{110}\x0a\x17\x0c\x17\x0e\ - \x17\u{113}\x0b\x17\x03\x18\x03\x18\x03\x18\x03\x18\x03\x18\x05\x18\u{11a}\ - \x0a\x18\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\ - \x03\x19\x03\x19\x03\x19\x03\x19\x05\x19\u{128}\x0a\x19\x03\x19\x03\x19\ - \x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x05\x19\u{132}\x0a\x19\ - \x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x05\x19\ - \u{13c}\x0a\x19\x03\x19\x03\x19\x05\x19\u{140}\x0a\x19\x03\x19\x03\x19\x05\ - \x19\u{144}\x0a\x19\x03\x19\x03\x19\x05\x19\u{148}\x0a\x19\x03\x19\x03\x19\ - \x03\x19\x03\x19\x03\x19\x03\x19\x03\x19\x05\x19\u{151}\x0a\x19\x03\x1a\ - \x03\x1a\x03\x1a\x03\x1a\x03\x1a\x03\x1b\x03\x1b\x03\x1b\x05\x1b\u{15b}\ - \x0a\x1b\x03\x1b\x03\x1b\x05\x1b\u{15f}\x0a\x1b\x03\x1b\x03\x1b\x03\x1c\ - \x03\x1c\x03\x1c\x05\x1c\u{166}\x0a\x1c\x03\x1c\x03\x1c\x03\x1c\x03\x1c\ - \x05\x1c\u{16c}\x0a\x1c\x03\x1c\x03\x1c\x03\x1d\x03\x1d\x06\x1d\u{172}\x0a\ - \x1d\x0d\x1d\x0e\x1d\u{173}\x03\x1d\x03\x1d\x05\x1d\u{178}\x0a\x1d\x03\x1d\ - \x03\x1d\x03\x1e\x03\x1e\x03\x1e\x03\x1e\x03\x1e\x03\x1f\x03\x1f\x03\x1f\ - \x03\x1f\x03\x1f\x03\x1f\x03\x1f\x03\x1f\x05\x1f\u{189}\x0a\x1f\x03\x20\ - \x03\x20\x03\x21\x03\x21\x03\x21\x02\x02\x22\x02\x04\x06\x08\x0a\x0c\x0e\ - \x10\x12\x14\x16\x18\x1a\x1c\x1e\x20\x22\x24\x26\x28\x2a\x2c\x2e\x30\x32\ - \x34\x36\x38\x3a\x3c\x3e\x40\x02\x07\x03\x02\x0c\x0e\x04\x02\x0f\x10\x3b\ - \x3b\x04\x02\x32\x32\x35\x35\x03\x02\x17\x18\x05\x02\x04\x04\x11\x16\x33\ - \x33\x02\u{1d4}\x02\x45\x03\x02\x02\x02\x04\x4c\x03\x02\x02\x02\x06\x4e\ - \x03\x02\x02\x02\x08\x53\x03\x02\x02\x02\x0a\x79\x03\x02\x02\x02\x0c\x7c\ - \x03\x02\x02\x02\x0e\u{81}\x03\x02\x02\x02\x10\u{85}\x03\x02\x02\x02\x12\ - \u{89}\x03\x02\x02\x02\x14\u{95}\x03\x02\x02\x02\x16\u{9a}\x03\x02\x02\x02\ - \x18\u{a8}\x03\x02\x02\x02\x1a\u{af}\x03\x02\x02\x02\x1c\u{c4}\x03\x02\x02\ - \x02\x1e\u{ca}\x03\x02\x02\x02\x20\u{d0}\x03\x02\x02\x02\x22\u{d8}\x03\x02\ - \x02\x02\x24\u{ec}\x03\x02\x02\x02\x26\u{ee}\x03\x02\x02\x02\x28\u{fe}\x03\ - \x02\x02\x02\x2a\u{104}\x03\x02\x02\x02\x2c\u{10c}\x03\x02\x02\x02\x2e\u{119}\ - \x03\x02\x02\x02\x30\u{150}\x03\x02\x02\x02\x32\u{152}\x03\x02\x02\x02\x34\ - \u{15a}\x03\x02\x02\x02\x36\u{165}\x03\x02\x02\x02\x38\u{16f}\x03\x02\x02\ - \x02\x3a\u{17b}\x03\x02\x02\x02\x3c\u{188}\x03\x02\x02\x02\x3e\u{18a}\x03\ - \x02\x02\x02\x40\u{18c}\x03\x02\x02\x02\x42\x44\x05\x04\x03\x02\x43\x42\ - \x03\x02\x02\x02\x44\x47\x03\x02\x02\x02\x45\x43\x03\x02\x02\x02\x45\x46\ - \x03\x02\x02\x02\x46\x48\x03\x02\x02\x02\x47\x45\x03\x02\x02\x02\x48\x49\ - \x07\x02\x02\x03\x49\x03\x03\x02\x02\x02\x4a\x4d\x05\x06\x04\x02\x4b\x4d\ - \x05\x08\x05\x02\x4c\x4a\x03\x02\x02\x02\x4c\x4b\x03\x02\x02\x02\x4d\x05\ - \x03\x02\x02\x02\x4e\x4f\x07\x03\x02\x02\x4f\x50\x07\x35\x02\x02\x50\x51\ - \x07\x04\x02\x02\x51\x52\x05\x08\x05\x02\x52\x07\x03\x02\x02\x02\x53\x58\ - \x05\x0a\x06\x02\x54\x55\x07\x05\x02\x02\x55\x57\x05\x0a\x06\x02\x56\x54\ - \x03\x02\x02\x02\x57\x5a\x03\x02\x02\x02\x58\x56\x03\x02\x02\x02\x58\x59\ - \x03\x02\x02\x02\x59\x09\x03\x02\x02\x02\x5a\x58\x03\x02\x02\x02\x5b\x5c\ - \x07\x35\x02\x02\x5c\x5e\x07\x06\x02\x02\x5d\x5f\x05\x1a\x0e\x02\x5e\x5d\ - \x03\x02\x02\x02\x5e\x5f\x03\x02\x02\x02\x5f\x60\x03\x02\x02\x02\x60\x61\ - \x07\x07\x02\x02\x61\x62\x07\x28\x02\x02\x62\x63\x07\x06\x02\x02\x63\x64\ - \x05\x0c\x07\x02\x64\x65\x07\x07\x02\x02\x65\x7a\x03\x02\x02\x02\x66\x67\ - \x07\x35\x02\x02\x67\x69\x07\x06\x02\x02\x68\x6a\x05\x1a\x0e\x02\x69\x68\ - \x03\x02\x02\x02\x69\x6a\x03\x02\x02\x02\x6a\x6b\x03\x02\x02\x02\x6b\x7a\ - \x07\x07\x02\x02\x6c\x7a\x07\x35\x02\x02\x6d\x6e\x07\x06\x02\x02\x6e\x6f\ - \x05\x08\x05\x02\x6f\x70\x07\x07\x02\x02\x70\x7a\x03\x02\x02\x02\x71\x7a\ - \x05\x18\x0d\x02\x72\x7a\x05\x16\x0c\x02\x73\x7a\x05\x38\x1d\x02\x74\x7a\ - \x07\x36\x02\x02\x75\x7a\x07\x37\x02\x02\x76\x7a\x07\x3b\x02\x02\x77\x7a\ - \x07\x38\x02\x02\x78\x7a\x07\x34\x02\x02\x79\x5b\x03\x02\x02\x02\x79\x66\ - \x03\x02\x02\x02\x79\x6c\x03\x02\x02\x02\x79\x6d\x03\x02\x02\x02\x79\x71\ - \x03\x02\x02\x02\x79\x72\x03\x02\x02\x02\x79\x73\x03\x02\x02\x02\x79\x74\ - \x03\x02\x02\x02\x79\x75\x03\x02\x02\x02\x79\x76\x03\x02\x02\x02\x79\x77\ - \x03\x02\x02\x02\x79\x78\x03\x02\x02\x02\x7a\x0b\x03\x02\x02\x02\x7b\x7d\ - \x05\x0e\x08\x02\x7c\x7b\x03\x02\x02\x02\x7c\x7d\x03\x02\x02\x02\x7d\x7f\ - \x03\x02\x02\x02\x7e\u{80}\x05\x10\x09\x02\x7f\x7e\x03\x02\x02\x02\x7f\u{80}\ - \x03\x02\x02\x02\u{80}\x0d\x03\x02\x02\x02\u{81}\u{82}\x07\x29\x02\x02\u{82}\ - \u{83}\x07\x2b\x02\x02\u{83}\u{84}\x05\x1a\x0e\x02\u{84}\x0f\x03\x02\x02\ - \x02\u{85}\u{86}\x07\x2a\x02\x02\u{86}\u{87}\x07\x2b\x02\x02\u{87}\u{88}\ - \x05\x12\x0a\x02\u{88}\x11\x03\x02\x02\x02\u{89}\u{8e}\x05\x14\x0b\x02\u{8a}\ - \u{8b}\x07\x08\x02\x02\u{8b}\u{8d}\x05\x14\x0b\x02\u{8c}\u{8a}\x03\x02\x02\ - \x02\u{8d}\u{90}\x03\x02\x02\x02\u{8e}\u{8c}\x03\x02\x02\x02\u{8e}\u{8f}\ - \x03\x02\x02\x02\u{8f}\x13\x03\x02\x02\x02\u{90}\u{8e}\x03\x02\x02\x02\u{91}\ - \u{96}\x05\x20\x11\x02\u{92}\u{96}\x05\x26\x14\x02\u{93}\u{96}\x05\x18\x0d\ - \x02\u{94}\u{96}\x07\x35\x02\x02\u{95}\u{91}\x03\x02\x02\x02\u{95}\u{92}\ - \x03\x02\x02\x02\u{95}\u{93}\x03\x02\x02\x02\u{95}\u{94}\x03\x02\x02\x02\ - \u{96}\u{98}\x03\x02\x02\x02\u{97}\u{99}\x05\x3e\x20\x02\u{98}\u{97}\x03\ - \x02\x02\x02\u{98}\u{99}\x03\x02\x02\x02\u{99}\x15\x03\x02\x02\x02\u{9a}\ - \u{9b}\x07\x09\x02\x02\u{9b}\u{9c}\x07\x06\x02\x02\u{9c}\u{a1}\x07\x35\x02\ - \x02\u{9d}\u{9e}\x07\x08\x02\x02\u{9e}\u{a0}\x07\x35\x02\x02\u{9f}\u{9d}\ - \x03\x02\x02\x02\u{a0}\u{a3}\x03\x02\x02\x02\u{a1}\u{9f}\x03\x02\x02\x02\ - \u{a1}\u{a2}\x03\x02\x02\x02\u{a2}\u{a4}\x03\x02\x02\x02\u{a3}\u{a1}\x03\ - \x02\x02\x02\u{a4}\u{a5}\x07\x07\x02\x02\u{a5}\u{a6}\x07\x0a\x02\x02\u{a6}\ - \u{a7}\x05\x2a\x16\x02\u{a7}\x17\x03\x02\x02\x02\u{a8}\u{ab}\x07\x35\x02\ - \x02\u{a9}\u{aa}\x07\x0b\x02\x02\u{aa}\u{ac}\x07\x35\x02\x02\u{ab}\u{a9}\ - \x03\x02\x02\x02\u{ac}\u{ad}\x03\x02\x02\x02\u{ad}\u{ab}\x03\x02\x02\x02\ - \u{ad}\u{ae}\x03\x02\x02\x02\u{ae}\x19\x03\x02\x02\x02\u{af}\u{b4}\x05\x1c\ - \x0f\x02\u{b0}\u{b1}\x07\x08\x02\x02\u{b1}\u{b3}\x05\x1c\x0f\x02\u{b2}\u{b0}\ - \x03\x02\x02\x02\u{b3}\u{b6}\x03\x02\x02\x02\u{b4}\u{b2}\x03\x02\x02\x02\ - \u{b4}\u{b5}\x03\x02\x02\x02\u{b5}\x1b\x03\x02\x02\x02\u{b6}\u{b4}\x03\x02\ - \x02\x02\u{b7}\u{c5}\x05\x1e\x10\x02\u{b8}\u{c5}\x05\x20\x11\x02\u{b9}\u{c5}\ - \x05\x26\x14\x02\u{ba}\u{c5}\x05\x38\x1d\x02\u{bb}\u{c5}\x05\x0a\x06\x02\ - \u{bc}\u{c5}\x05\x28\x15\x02\u{bd}\u{c5}\x05\x30\x19\x02\u{be}\u{c5}\x05\ - \x08\x05\x02\u{bf}\u{c5}\x05\x16\x0c\x02\u{c0}\u{c1}\x07\x06\x02\x02\u{c1}\ - \u{c2}\x05\x08\x05\x02\u{c2}\u{c3}\x07\x07\x02\x02\u{c3}\u{c5}\x03\x02\x02\ - \x02\u{c4}\u{b7}\x03\x02\x02\x02\u{c4}\u{b8}\x03\x02\x02\x02\u{c4}\u{b9}\ - \x03\x02\x02\x02\u{c4}\u{ba}\x03\x02\x02\x02\u{c4}\u{bb}\x03\x02\x02\x02\ - \u{c4}\u{bc}\x03\x02\x02\x02\u{c4}\u{bd}\x03\x02\x02\x02\u{c4}\u{be}\x03\ - \x02\x02\x02\u{c4}\u{bf}\x03\x02\x02\x02\u{c4}\u{c0}\x03\x02\x02\x02\u{c5}\ - \x1d\x03\x02\x02\x02\u{c6}\u{cb}\x05\x20\x11\x02\u{c7}\u{cb}\x05\x26\x14\ - \x02\u{c8}\u{cb}\x05\x18\x0d\x02\u{c9}\u{cb}\x07\x35\x02\x02\u{ca}\u{c6}\ - \x03\x02\x02\x02\u{ca}\u{c7}\x03\x02\x02\x02\u{ca}\u{c8}\x03\x02\x02\x02\ - \u{ca}\u{c9}\x03\x02\x02\x02\u{cb}\u{ce}\x03\x02\x02\x02\u{cc}\u{cd}\x07\ - \x21\x02\x02\u{cd}\u{cf}\x07\x35\x02\x02\u{ce}\u{cc}\x03\x02\x02\x02\u{ce}\ - \u{cf}\x03\x02\x02\x02\u{cf}\x1f\x03\x02\x02\x02\u{d0}\u{d5}\x05\x22\x12\ - \x02\u{d1}\u{d2}\x09\x02\x02\x02\u{d2}\u{d4}\x05\x22\x12\x02\u{d3}\u{d1}\ - \x03\x02\x02\x02\u{d4}\u{d7}\x03\x02\x02\x02\u{d5}\u{d3}\x03\x02\x02\x02\ - \u{d5}\u{d6}\x03\x02\x02\x02\u{d6}\x21\x03\x02\x02\x02\u{d7}\u{d5}\x03\x02\ - \x02\x02\u{d8}\u{dd}\x05\x24\x13\x02\u{d9}\u{da}\x09\x03\x02\x02\u{da}\u{dc}\ - \x05\x24\x13\x02\u{db}\u{d9}\x03\x02\x02\x02\u{dc}\u{df}\x03\x02\x02\x02\ - \u{dd}\u{db}\x03\x02\x02\x02\u{dd}\u{de}\x03\x02\x02\x02\u{de}\x23\x03\x02\ - \x02\x02\u{df}\u{dd}\x03\x02\x02\x02\u{e0}\u{ed}\x05\x18\x0d\x02\u{e1}\u{ed}\ - \x07\x35\x02\x02\u{e2}\u{ed}\x07\x36\x02\x02\u{e3}\u{ed}\x07\x37\x02\x02\ - \u{e4}\u{ed}\x07\x38\x02\x02\u{e5}\u{ed}\x05\x26\x14\x02\u{e6}\u{ed}\x05\ - \x38\x1d\x02\u{e7}\u{ed}\x07\x34\x02\x02\u{e8}\u{e9}\x07\x06\x02\x02\u{e9}\ - \u{ea}\x05\x20\x11\x02\u{ea}\u{eb}\x07\x07\x02\x02\u{eb}\u{ed}\x03\x02\x02\ - \x02\u{ec}\u{e0}\x03\x02\x02\x02\u{ec}\u{e1}\x03\x02\x02\x02\u{ec}\u{e2}\ - \x03\x02\x02\x02\u{ec}\u{e3}\x03\x02\x02\x02\u{ec}\u{e4}\x03\x02\x02\x02\ - \u{ec}\u{e5}\x03\x02\x02\x02\u{ec}\u{e6}\x03\x02\x02\x02\u{ec}\u{e7}\x03\ - \x02\x02\x02\u{ec}\u{e8}\x03\x02\x02\x02\u{ed}\x25\x03\x02\x02\x02\u{ee}\ - \u{ef}\x07\x35\x02\x02\u{ef}\u{f4}\x07\x06\x02\x02\u{f0}\u{f2}\x07\x1b\x02\ - \x02\u{f1}\u{f0}\x03\x02\x02\x02\u{f1}\u{f2}\x03\x02\x02\x02\u{f2}\u{f3}\ - \x03\x02\x02\x02\u{f3}\u{f5}\x05\x1a\x0e\x02\u{f4}\u{f1}\x03\x02\x02\x02\ - \u{f4}\u{f5}\x03\x02\x02\x02\u{f5}\u{f6}\x03\x02\x02\x02\u{f6}\u{fc}\x07\ - \x07\x02\x02\u{f7}\u{f8}\x07\x28\x02\x02\u{f8}\u{f9}\x07\x06\x02\x02\u{f9}\ - \u{fa}\x05\x0c\x07\x02\u{fa}\u{fb}\x07\x07\x02\x02\u{fb}\u{fd}\x03\x02\x02\ - \x02\u{fc}\u{f7}\x03\x02\x02\x02\u{fc}\u{fd}\x03\x02\x02\x02\u{fd}\x27\x03\ - \x02\x02\x02\u{fe}\u{ff}\x09\x04\x02\x02\u{ff}\u{102}\x07\x04\x02\x02\u{100}\ - \u{103}\x05\x30\x19\x02\u{101}\u{103}\x05\x2a\x16\x02\u{102}\u{100}\x03\ - \x02\x02\x02\u{102}\u{101}\x03\x02\x02\x02\u{103}\x29\x03\x02\x02\x02\u{104}\ - \u{109}\x05\x2c\x17\x02\u{105}\u{106}\x07\x1a\x02\x02\u{106}\u{108}\x05\ - \x2c\x17\x02\u{107}\u{105}\x03\x02\x02\x02\u{108}\u{10b}\x03\x02\x02\x02\ - \u{109}\u{107}\x03\x02\x02\x02\u{109}\u{10a}\x03\x02\x02\x02\u{10a}\x2b\ - \x03\x02\x02\x02\u{10b}\u{109}\x03\x02\x02\x02\u{10c}\u{111}\x05\x2e\x18\ - \x02\u{10d}\u{10e}\x07\x19\x02\x02\u{10e}\u{110}\x05\x2e\x18\x02\u{10f}\ - \u{10d}\x03\x02\x02\x02\u{110}\u{113}\x03\x02\x02\x02\u{111}\u{10f}\x03\ - \x02\x02\x02\u{111}\u{112}\x03\x02\x02\x02\u{112}\x2d\x03\x02\x02\x02\u{113}\ - \u{111}\x03\x02\x02\x02\u{114}\u{11a}\x05\x30\x19\x02\u{115}\u{116}\x07\ - \x06\x02\x02\u{116}\u{117}\x05\x2a\x16\x02\u{117}\u{118}\x07\x07\x02\x02\ - \u{118}\u{11a}\x03\x02\x02\x02\u{119}\u{114}\x03\x02\x02\x02\u{119}\u{115}\ - \x03\x02\x02\x02\u{11a}\x2f\x03\x02\x02\x02\u{11b}\u{11c}\x05\x20\x11\x02\ - \u{11c}\u{11d}\x05\x40\x21\x02\u{11d}\u{11e}\x05\x20\x11\x02\u{11e}\u{151}\ - \x03\x02\x02\x02\u{11f}\u{120}\x05\x18\x0d\x02\u{120}\u{127}\x05\x40\x21\ - \x02\u{121}\u{128}\x05\x18\x0d\x02\u{122}\u{128}\x07\x38\x02\x02\u{123}\ - \u{128}\x07\x35\x02\x02\u{124}\u{128}\x07\x36\x02\x02\u{125}\u{128}\x07\ - \x37\x02\x02\u{126}\u{128}\x07\x34\x02\x02\u{127}\u{121}\x03\x02\x02\x02\ - \u{127}\u{122}\x03\x02\x02\x02\u{127}\u{123}\x03\x02\x02\x02\u{127}\u{124}\ - \x03\x02\x02\x02\u{127}\u{125}\x03\x02\x02\x02\u{127}\u{126}\x03\x02\x02\ - \x02\u{128}\u{151}\x03\x02\x02\x02\u{129}\u{12a}\x07\x35\x02\x02\u{12a}\ - \u{131}\x05\x40\x21\x02\u{12b}\u{132}\x05\x18\x0d\x02\u{12c}\u{132}\x07\ - \x38\x02\x02\u{12d}\u{132}\x07\x35\x02\x02\u{12e}\u{132}\x07\x36\x02\x02\ - \u{12f}\u{132}\x07\x37\x02\x02\u{130}\u{132}\x07\x34\x02\x02\u{131}\u{12b}\ - \x03\x02\x02\x02\u{131}\u{12c}\x03\x02\x02\x02\u{131}\u{12d}\x03\x02\x02\ - \x02\u{131}\u{12e}\x03\x02\x02\x02\u{131}\u{12f}\x03\x02\x02\x02\u{131}\ - \u{130}\x03\x02\x02\x02\u{132}\u{151}\x03\x02\x02\x02\u{133}\u{134}\x07\ - \x34\x02\x02\u{134}\u{13b}\x05\x40\x21\x02\u{135}\u{13c}\x05\x18\x0d\x02\ - \u{136}\u{13c}\x07\x38\x02\x02\u{137}\u{13c}\x07\x35\x02\x02\u{138}\u{13c}\ - \x07\x36\x02\x02\u{139}\u{13c}\x07\x37\x02\x02\u{13a}\u{13c}\x07\x34\x02\ - \x02\u{13b}\u{135}\x03\x02\x02\x02\u{13b}\u{136}\x03\x02\x02\x02\u{13b}\ - \u{137}\x03\x02\x02\x02\u{13b}\u{138}\x03\x02\x02\x02\u{13b}\u{139}\x03\ - \x02\x02\x02\u{13b}\u{13a}\x03\x02\x02\x02\u{13c}\u{151}\x03\x02\x02\x02\ - \u{13d}\u{13f}\x05\x18\x0d\x02\u{13e}\u{140}\x05\x3e\x20\x02\u{13f}\u{13e}\ - \x03\x02\x02\x02\u{13f}\u{140}\x03\x02\x02\x02\u{140}\u{151}\x03\x02\x02\ - \x02\u{141}\u{143}\x07\x35\x02\x02\u{142}\u{144}\x05\x3e\x20\x02\u{143}\ - \u{142}\x03\x02\x02\x02\u{143}\u{144}\x03\x02\x02\x02\u{144}\u{151}\x03\ - \x02\x02\x02\u{145}\u{147}\x07\x34\x02\x02\u{146}\u{148}\x05\x3e\x20\x02\ - \u{147}\u{146}\x03\x02\x02\x02\u{147}\u{148}\x03\x02\x02\x02\u{148}\u{151}\ - \x03\x02\x02\x02\u{149}\u{151}\x07\x38\x02\x02\u{14a}\u{151}\x07\x36\x02\ - \x02\u{14b}\u{151}\x07\x37\x02\x02\u{14c}\u{151}\x05\x0a\x06\x02\u{14d}\ - \u{151}\x05\x32\x1a\x02\u{14e}\u{151}\x05\x34\x1b\x02\u{14f}\u{151}\x05\ - \x36\x1c\x02\u{150}\u{11b}\x03\x02\x02\x02\u{150}\u{11f}\x03\x02\x02\x02\ - \u{150}\u{129}\x03\x02\x02\x02\u{150}\u{133}\x03\x02\x02\x02\u{150}\u{13d}\ - \x03\x02\x02\x02\u{150}\u{141}\x03\x02\x02\x02\u{150}\u{145}\x03\x02\x02\ - \x02\u{150}\u{149}\x03\x02\x02\x02\u{150}\u{14a}\x03\x02\x02\x02\u{150}\ - \u{14b}\x03\x02\x02\x02\u{150}\u{14c}\x03\x02\x02\x02\u{150}\u{14d}\x03\ - \x02\x02\x02\u{150}\u{14e}\x03\x02\x02\x02\u{150}\u{14f}\x03\x02\x02\x02\ - \u{151}\x31\x03\x02\x02\x02\u{152}\u{153}\x07\x1c\x02\x02\u{153}\u{154}\ - \x07\x06\x02\x02\u{154}\u{155}\x05\x08\x05\x02\u{155}\u{156}\x07\x07\x02\ - \x02\u{156}\x33\x03\x02\x02\x02\u{157}\u{15b}\x05\x18\x0d\x02\u{158}\u{15b}\ - \x07\x35\x02\x02\u{159}\u{15b}\x07\x34\x02\x02\u{15a}\u{157}\x03\x02\x02\ - \x02\u{15a}\u{158}\x03\x02\x02\x02\u{15a}\u{159}\x03\x02\x02\x02\u{15b}\ - \u{15c}\x03\x02\x02\x02\u{15c}\u{15e}\x07\x1e\x02\x02\u{15d}\u{15f}\x07\ - \x1f\x02\x02\u{15e}\u{15d}\x03\x02\x02\x02\u{15e}\u{15f}\x03\x02\x02\x02\ - \u{15f}\u{160}\x03\x02\x02\x02\u{160}\u{161}\x07\x1d\x02\x02\u{161}\x35\ - \x03\x02\x02\x02\u{162}\u{166}\x05\x18\x0d\x02\u{163}\u{166}\x07\x35\x02\ - \x02\u{164}\u{166}\x07\x34\x02\x02\u{165}\u{162}\x03\x02\x02\x02\u{165}\ - \u{163}\x03\x02\x02\x02\u{165}\u{164}\x03\x02\x02\x02\u{166}\u{167}\x03\ - \x02\x02\x02\u{167}\u{168}\x07\x20\x02\x02\u{168}\u{16b}\x07\x06\x02\x02\ - \u{169}\u{16c}\x05\x08\x05\x02\u{16a}\u{16c}\x05\x1a\x0e\x02\u{16b}\u{169}\ - \x03\x02\x02\x02\u{16b}\u{16a}\x03\x02\x02\x02\u{16c}\u{16d}\x03\x02\x02\ - \x02\u{16d}\u{16e}\x07\x07\x02\x02\u{16e}\x37\x03\x02\x02\x02\u{16f}\u{171}\ - \x07\x22\x02\x02\u{170}\u{172}\x05\x3a\x1e\x02\u{171}\u{170}\x03\x02\x02\ - \x02\u{172}\u{173}\x03\x02\x02\x02\u{173}\u{171}\x03\x02\x02\x02\u{173}\ - \u{174}\x03\x02\x02\x02\u{174}\u{177}\x03\x02\x02\x02\u{175}\u{176}\x07\ - \x25\x02\x02\u{176}\u{178}\x05\x3c\x1f\x02\u{177}\u{175}\x03\x02\x02\x02\ - \u{177}\u{178}\x03\x02\x02\x02\u{178}\u{179}\x03\x02\x02\x02\u{179}\u{17a}\ - \x07\x26\x02\x02\u{17a}\x39\x03\x02\x02\x02\u{17b}\u{17c}\x07\x23\x02\x02\ - \u{17c}\u{17d}\x05\x30\x19\x02\u{17d}\u{17e}\x07\x24\x02\x02\u{17e}\u{17f}\ - \x05\x3c\x1f\x02\u{17f}\x3b\x03\x02\x02\x02\u{180}\u{189}\x05\x20\x11\x02\ - \u{181}\u{189}\x05\x30\x19\x02\u{182}\u{189}\x05\x18\x0d\x02\u{183}\u{189}\ - \x07\x35\x02\x02\u{184}\u{189}\x07\x36\x02\x02\u{185}\u{189}\x07\x37\x02\ - \x02\u{186}\u{189}\x07\x38\x02\x02\u{187}\u{189}\x07\x34\x02\x02\u{188}\ - \u{180}\x03\x02\x02\x02\u{188}\u{181}\x03\x02\x02\x02\u{188}\u{182}\x03\ - \x02\x02\x02\u{188}\u{183}\x03\x02\x02\x02\u{188}\u{184}\x03\x02\x02\x02\ - \u{188}\u{185}\x03\x02\x02\x02\u{188}\u{186}\x03\x02\x02\x02\u{188}\u{187}\ - \x03\x02\x02\x02\u{189}\x3d\x03\x02\x02\x02\u{18a}\u{18b}\x09\x05\x02\x02\ - \u{18b}\x3f\x03\x02\x02\x02\u{18c}\u{18d}\x09\x06\x02\x02\u{18d}\x41\x03\ - \x02\x02\x02\x2b\x45\x4c\x58\x5e\x69\x79\x7c\x7f\u{8e}\u{95}\u{98}\u{a1}\ - \u{ad}\u{b4}\u{c4}\u{ca}\u{ce}\u{d5}\u{dd}\u{ec}\u{f1}\u{f4}\u{fc}\u{102}\ - \u{109}\u{111}\u{119}\u{127}\u{131}\u{13b}\u{13f}\u{143}\u{147}\u{150}\u{15a}\ - \u{15e}\u{165}\u{16b}\u{173}\u{177}\u{188}"; + \x20\x04\x21\x09\x21\x04\x22\x09\x22\x04\x23\x09\x23\x03\x02\x07\x02\x48\ + \x0a\x02\x0c\x02\x0e\x02\x4b\x0b\x02\x03\x02\x03\x02\x03\x03\x03\x03\x03\ + \x03\x05\x03\x52\x0a\x03\x03\x04\x03\x04\x03\x04\x03\x04\x07\x04\x58\x0a\ + \x04\x0c\x04\x0e\x04\x5b\x0b\x04\x03\x04\x03\x04\x03\x05\x03\x05\x03\x05\ + \x03\x05\x03\x05\x03\x05\x03\x06\x03\x06\x03\x06\x03\x06\x03\x06\x03\x07\ + \x03\x07\x03\x07\x07\x07\x6d\x0a\x07\x0c\x07\x0e\x07\x70\x0b\x07\x03\x08\ + \x03\x08\x03\x08\x05\x08\x75\x0a\x08\x03\x08\x03\x08\x03\x08\x03\x08\x03\ + \x08\x03\x08\x03\x08\x03\x08\x03\x08\x05\x08\u{80}\x0a\x08\x03\x08\x03\x08\ + \x03\x08\x03\x08\x03\x08\x03\x08\x03\x08\x03\x08\x03\x08\x03\x08\x03\x08\ + \x03\x08\x03\x08\x03\x08\x05\x08\u{90}\x0a\x08\x03\x09\x05\x09\u{93}\x0a\ + \x09\x03\x09\x05\x09\u{96}\x0a\x09\x03\x0a\x03\x0a\x03\x0a\x03\x0a\x03\x0b\ + \x03\x0b\x03\x0b\x03\x0b\x03\x0c\x03\x0c\x03\x0c\x07\x0c\u{a3}\x0a\x0c\x0c\ + \x0c\x0e\x0c\u{a6}\x0b\x0c\x03\x0d\x03\x0d\x03\x0d\x03\x0d\x05\x0d\u{ac}\ + \x0a\x0d\x03\x0d\x05\x0d\u{af}\x0a\x0d\x03\x0e\x03\x0e\x03\x0e\x03\x0e\x03\ + \x0e\x07\x0e\u{b6}\x0a\x0e\x0c\x0e\x0e\x0e\u{b9}\x0b\x0e\x03\x0e\x03\x0e\ + \x03\x0e\x03\x0e\x03\x0f\x03\x0f\x03\x0f\x06\x0f\u{c2}\x0a\x0f\x0d\x0f\x0e\ + \x0f\u{c3}\x03\x10\x03\x10\x03\x10\x07\x10\u{c9}\x0a\x10\x0c\x10\x0e\x10\ + \u{cc}\x0b\x10\x03\x11\x03\x11\x03\x11\x03\x11\x03\x11\x03\x11\x03\x11\x03\ + \x11\x03\x11\x03\x11\x03\x11\x03\x11\x03\x11\x05\x11\u{db}\x0a\x11\x03\x12\ + \x03\x12\x03\x12\x03\x12\x05\x12\u{e1}\x0a\x12\x03\x12\x03\x12\x05\x12\u{e5}\ + \x0a\x12\x03\x13\x03\x13\x03\x13\x07\x13\u{ea}\x0a\x13\x0c\x13\x0e\x13\u{ed}\ + \x0b\x13\x03\x14\x03\x14\x03\x14\x07\x14\u{f2}\x0a\x14\x0c\x14\x0e\x14\u{f5}\ + \x0b\x14\x03\x15\x03\x15\x03\x15\x03\x15\x03\x15\x03\x15\x03\x15\x03\x15\ + \x03\x15\x03\x15\x03\x15\x03\x15\x05\x15\u{103}\x0a\x15\x03\x16\x03\x16\ + \x03\x16\x05\x16\u{108}\x0a\x16\x03\x16\x05\x16\u{10b}\x0a\x16\x03\x16\x03\ + \x16\x03\x16\x03\x16\x03\x16\x03\x16\x05\x16\u{113}\x0a\x16\x03\x17\x03\ + \x17\x03\x17\x03\x17\x05\x17\u{119}\x0a\x17\x03\x18\x03\x18\x03\x18\x07\ + \x18\u{11e}\x0a\x18\x0c\x18\x0e\x18\u{121}\x0b\x18\x03\x19\x03\x19\x03\x19\ + \x07\x19\u{126}\x0a\x19\x0c\x19\x0e\x19\u{129}\x0b\x19\x03\x1a\x03\x1a\x03\ + \x1a\x03\x1a\x03\x1a\x05\x1a\u{130}\x0a\x1a\x03\x1b\x03\x1b\x03\x1b\x03\ + \x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x05\ + \x1b\u{13e}\x0a\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\ + \x1b\x03\x1b\x05\x1b\u{148}\x0a\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\ + \x1b\x03\x1b\x03\x1b\x03\x1b\x05\x1b\u{152}\x0a\x1b\x03\x1b\x03\x1b\x05\ + \x1b\u{156}\x0a\x1b\x03\x1b\x03\x1b\x05\x1b\u{15a}\x0a\x1b\x03\x1b\x03\x1b\ + \x05\x1b\u{15e}\x0a\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\x03\x1b\ + \x03\x1b\x05\x1b\u{167}\x0a\x1b\x03\x1c\x03\x1c\x03\x1c\x03\x1c\x03\x1c\ + \x03\x1d\x03\x1d\x03\x1d\x05\x1d\u{171}\x0a\x1d\x03\x1d\x03\x1d\x05\x1d\ + \u{175}\x0a\x1d\x03\x1d\x03\x1d\x03\x1e\x03\x1e\x03\x1e\x05\x1e\u{17c}\x0a\ + \x1e\x03\x1e\x03\x1e\x03\x1e\x03\x1e\x05\x1e\u{182}\x0a\x1e\x03\x1e\x03\ + \x1e\x03\x1f\x03\x1f\x06\x1f\u{188}\x0a\x1f\x0d\x1f\x0e\x1f\u{189}\x03\x1f\ + \x03\x1f\x05\x1f\u{18e}\x0a\x1f\x03\x1f\x03\x1f\x03\x20\x03\x20\x03\x20\ + \x03\x20\x03\x20\x03\x21\x03\x21\x03\x21\x03\x21\x03\x21\x03\x21\x03\x21\ + \x03\x21\x05\x21\u{19f}\x0a\x21\x03\x22\x03\x22\x03\x23\x03\x23\x03\x23\ + \x02\x02\x24\x02\x04\x06\x08\x0a\x0c\x0e\x10\x12\x14\x16\x18\x1a\x1c\x1e\ + \x20\x22\x24\x26\x28\x2a\x2c\x2e\x30\x32\x34\x36\x38\x3a\x3c\x3e\x40\x42\ + \x44\x02\x07\x03\x02\x0c\x0e\x04\x02\x0f\x10\x3b\x3b\x04\x02\x32\x32\x35\ + \x35\x03\x02\x17\x18\x05\x02\x07\x07\x11\x16\x33\x33\x02\u{1ea}\x02\x49\ + \x03\x02\x02\x02\x04\x51\x03\x02\x02\x02\x06\x53\x03\x02\x02\x02\x08\x5e\ + \x03\x02\x02\x02\x0a\x64\x03\x02\x02\x02\x0c\x69\x03\x02\x02\x02\x0e\u{8f}\ + \x03\x02\x02\x02\x10\u{92}\x03\x02\x02\x02\x12\u{97}\x03\x02\x02\x02\x14\ + \u{9b}\x03\x02\x02\x02\x16\u{9f}\x03\x02\x02\x02\x18\u{ab}\x03\x02\x02\x02\ + \x1a\u{b0}\x03\x02\x02\x02\x1c\u{be}\x03\x02\x02\x02\x1e\u{c5}\x03\x02\x02\ + \x02\x20\u{da}\x03\x02\x02\x02\x22\u{e0}\x03\x02\x02\x02\x24\u{e6}\x03\x02\ + \x02\x02\x26\u{ee}\x03\x02\x02\x02\x28\u{102}\x03\x02\x02\x02\x2a\u{104}\ + \x03\x02\x02\x02\x2c\u{114}\x03\x02\x02\x02\x2e\u{11a}\x03\x02\x02\x02\x30\ + \u{122}\x03\x02\x02\x02\x32\u{12f}\x03\x02\x02\x02\x34\u{166}\x03\x02\x02\ + \x02\x36\u{168}\x03\x02\x02\x02\x38\u{170}\x03\x02\x02\x02\x3a\u{17b}\x03\ + \x02\x02\x02\x3c\u{185}\x03\x02\x02\x02\x3e\u{191}\x03\x02\x02\x02\x40\u{19e}\ + \x03\x02\x02\x02\x42\u{1a0}\x03\x02\x02\x02\x44\u{1a2}\x03\x02\x02\x02\x46\ + \x48\x05\x04\x03\x02\x47\x46\x03\x02\x02\x02\x48\x4b\x03\x02\x02\x02\x49\ + \x47\x03\x02\x02\x02\x49\x4a\x03\x02\x02\x02\x4a\x4c\x03\x02\x02\x02\x4b\ + \x49\x03\x02\x02\x02\x4c\x4d\x07\x02\x02\x03\x4d\x03\x03\x02\x02\x02\x4e\ + \x52\x05\x06\x04\x02\x4f\x52\x05\x0a\x06\x02\x50\x52\x05\x0c\x07\x02\x51\ + \x4e\x03\x02\x02\x02\x51\x4f\x03\x02\x02\x02\x51\x50\x03\x02\x02\x02\x52\ + \x05\x03\x02\x02\x02\x53\x54\x07\x27\x02\x02\x54\x59\x05\x08\x05\x02\x55\ + \x56\x07\x03\x02\x02\x56\x58\x05\x08\x05\x02\x57\x55\x03\x02\x02\x02\x58\ + \x5b\x03\x02\x02\x02\x59\x57\x03\x02\x02\x02\x59\x5a\x03\x02\x02\x02\x5a\ + \x5c\x03\x02\x02\x02\x5b\x59\x03\x02\x02\x02\x5c\x5d\x05\x0c\x07\x02\x5d\ + \x07\x03\x02\x02\x02\x5e\x5f\x07\x35\x02\x02\x5f\x60\x07\x21\x02\x02\x60\ + \x61\x07\x04\x02\x02\x61\x62\x05\x0c\x07\x02\x62\x63\x07\x05\x02\x02\x63\ + \x09\x03\x02\x02\x02\x64\x65\x07\x06\x02\x02\x65\x66\x07\x35\x02\x02\x66\ + \x67\x07\x07\x02\x02\x67\x68\x05\x0c\x07\x02\x68\x0b\x03\x02\x02\x02\x69\ + \x6e\x05\x0e\x08\x02\x6a\x6b\x07\x08\x02\x02\x6b\x6d\x05\x0e\x08\x02\x6c\ + \x6a\x03\x02\x02\x02\x6d\x70\x03\x02\x02\x02\x6e\x6c\x03\x02\x02\x02\x6e\ + \x6f\x03\x02\x02\x02\x6f\x0d\x03\x02\x02\x02\x70\x6e\x03\x02\x02\x02\x71\ + \x72\x07\x35\x02\x02\x72\x74\x07\x04\x02\x02\x73\x75\x05\x1e\x10\x02\x74\ + \x73\x03\x02\x02\x02\x74\x75\x03\x02\x02\x02\x75\x76\x03\x02\x02\x02\x76\ + \x77\x07\x05\x02\x02\x77\x78\x07\x28\x02\x02\x78\x79\x07\x04\x02\x02\x79\ + \x7a\x05\x10\x09\x02\x7a\x7b\x07\x05\x02\x02\x7b\u{90}\x03\x02\x02\x02\x7c\ + \x7d\x07\x35\x02\x02\x7d\x7f\x07\x04\x02\x02\x7e\u{80}\x05\x1e\x10\x02\x7f\ + \x7e\x03\x02\x02\x02\x7f\u{80}\x03\x02\x02\x02\u{80}\u{81}\x03\x02\x02\x02\ + \u{81}\u{90}\x07\x05\x02\x02\u{82}\u{90}\x07\x35\x02\x02\u{83}\u{84}\x07\ + \x04\x02\x02\u{84}\u{85}\x05\x0c\x07\x02\u{85}\u{86}\x07\x05\x02\x02\u{86}\ + \u{90}\x03\x02\x02\x02\u{87}\u{90}\x05\x1c\x0f\x02\u{88}\u{90}\x05\x1a\x0e\ + \x02\u{89}\u{90}\x05\x3c\x1f\x02\u{8a}\u{90}\x07\x36\x02\x02\u{8b}\u{90}\ + \x07\x37\x02\x02\u{8c}\u{90}\x07\x3b\x02\x02\u{8d}\u{90}\x07\x38\x02\x02\ + \u{8e}\u{90}\x07\x34\x02\x02\u{8f}\x71\x03\x02\x02\x02\u{8f}\x7c\x03\x02\ + \x02\x02\u{8f}\u{82}\x03\x02\x02\x02\u{8f}\u{83}\x03\x02\x02\x02\u{8f}\u{87}\ + \x03\x02\x02\x02\u{8f}\u{88}\x03\x02\x02\x02\u{8f}\u{89}\x03\x02\x02\x02\ + \u{8f}\u{8a}\x03\x02\x02\x02\u{8f}\u{8b}\x03\x02\x02\x02\u{8f}\u{8c}\x03\ + \x02\x02\x02\u{8f}\u{8d}\x03\x02\x02\x02\u{8f}\u{8e}\x03\x02\x02\x02\u{90}\ + \x0f\x03\x02\x02\x02\u{91}\u{93}\x05\x12\x0a\x02\u{92}\u{91}\x03\x02\x02\ + \x02\u{92}\u{93}\x03\x02\x02\x02\u{93}\u{95}\x03\x02\x02\x02\u{94}\u{96}\ + \x05\x14\x0b\x02\u{95}\u{94}\x03\x02\x02\x02\u{95}\u{96}\x03\x02\x02\x02\ + \u{96}\x11\x03\x02\x02\x02\u{97}\u{98}\x07\x29\x02\x02\u{98}\u{99}\x07\x2b\ + \x02\x02\u{99}\u{9a}\x05\x1e\x10\x02\u{9a}\x13\x03\x02\x02\x02\u{9b}\u{9c}\ + \x07\x2a\x02\x02\u{9c}\u{9d}\x07\x2b\x02\x02\u{9d}\u{9e}\x05\x16\x0c\x02\ + \u{9e}\x15\x03\x02\x02\x02\u{9f}\u{a4}\x05\x18\x0d\x02\u{a0}\u{a1}\x07\x03\ + \x02\x02\u{a1}\u{a3}\x05\x18\x0d\x02\u{a2}\u{a0}\x03\x02\x02\x02\u{a3}\u{a6}\ + \x03\x02\x02\x02\u{a4}\u{a2}\x03\x02\x02\x02\u{a4}\u{a5}\x03\x02\x02\x02\ + \u{a5}\x17\x03\x02\x02\x02\u{a6}\u{a4}\x03\x02\x02\x02\u{a7}\u{ac}\x05\x24\ + \x13\x02\u{a8}\u{ac}\x05\x2a\x16\x02\u{a9}\u{ac}\x05\x1c\x0f\x02\u{aa}\u{ac}\ + \x07\x35\x02\x02\u{ab}\u{a7}\x03\x02\x02\x02\u{ab}\u{a8}\x03\x02\x02\x02\ + \u{ab}\u{a9}\x03\x02\x02\x02\u{ab}\u{aa}\x03\x02\x02\x02\u{ac}\u{ae}\x03\ + \x02\x02\x02\u{ad}\u{af}\x05\x42\x22\x02\u{ae}\u{ad}\x03\x02\x02\x02\u{ae}\ + \u{af}\x03\x02\x02\x02\u{af}\x19\x03\x02\x02\x02\u{b0}\u{b1}\x07\x09\x02\ + \x02\u{b1}\u{b2}\x07\x04\x02\x02\u{b2}\u{b7}\x07\x35\x02\x02\u{b3}\u{b4}\ + \x07\x03\x02\x02\u{b4}\u{b6}\x07\x35\x02\x02\u{b5}\u{b3}\x03\x02\x02\x02\ + \u{b6}\u{b9}\x03\x02\x02\x02\u{b7}\u{b5}\x03\x02\x02\x02\u{b7}\u{b8}\x03\ + \x02\x02\x02\u{b8}\u{ba}\x03\x02\x02\x02\u{b9}\u{b7}\x03\x02\x02\x02\u{ba}\ + \u{bb}\x07\x05\x02\x02\u{bb}\u{bc}\x07\x0a\x02\x02\u{bc}\u{bd}\x05\x2e\x18\ + \x02\u{bd}\x1b\x03\x02\x02\x02\u{be}\u{c1}\x07\x35\x02\x02\u{bf}\u{c0}\x07\ + \x0b\x02\x02\u{c0}\u{c2}\x07\x35\x02\x02\u{c1}\u{bf}\x03\x02\x02\x02\u{c2}\ + \u{c3}\x03\x02\x02\x02\u{c3}\u{c1}\x03\x02\x02\x02\u{c3}\u{c4}\x03\x02\x02\ + \x02\u{c4}\x1d\x03\x02\x02\x02\u{c5}\u{ca}\x05\x20\x11\x02\u{c6}\u{c7}\x07\ + \x03\x02\x02\u{c7}\u{c9}\x05\x20\x11\x02\u{c8}\u{c6}\x03\x02\x02\x02\u{c9}\ + \u{cc}\x03\x02\x02\x02\u{ca}\u{c8}\x03\x02\x02\x02\u{ca}\u{cb}\x03\x02\x02\ + \x02\u{cb}\x1f\x03\x02\x02\x02\u{cc}\u{ca}\x03\x02\x02\x02\u{cd}\u{db}\x05\ + \x22\x12\x02\u{ce}\u{db}\x05\x24\x13\x02\u{cf}\u{db}\x05\x2a\x16\x02\u{d0}\ + \u{db}\x05\x3c\x1f\x02\u{d1}\u{db}\x05\x0e\x08\x02\u{d2}\u{db}\x05\x2c\x17\ + \x02\u{d3}\u{db}\x05\x34\x1b\x02\u{d4}\u{db}\x05\x0c\x07\x02\u{d5}\u{db}\ + \x05\x1a\x0e\x02\u{d6}\u{d7}\x07\x04\x02\x02\u{d7}\u{d8}\x05\x0c\x07\x02\ + \u{d8}\u{d9}\x07\x05\x02\x02\u{d9}\u{db}\x03\x02\x02\x02\u{da}\u{cd}\x03\ + \x02\x02\x02\u{da}\u{ce}\x03\x02\x02\x02\u{da}\u{cf}\x03\x02\x02\x02\u{da}\ + \u{d0}\x03\x02\x02\x02\u{da}\u{d1}\x03\x02\x02\x02\u{da}\u{d2}\x03\x02\x02\ + \x02\u{da}\u{d3}\x03\x02\x02\x02\u{da}\u{d4}\x03\x02\x02\x02\u{da}\u{d5}\ + \x03\x02\x02\x02\u{da}\u{d6}\x03\x02\x02\x02\u{db}\x21\x03\x02\x02\x02\u{dc}\ + \u{e1}\x05\x24\x13\x02\u{dd}\u{e1}\x05\x2a\x16\x02\u{de}\u{e1}\x05\x1c\x0f\ + \x02\u{df}\u{e1}\x07\x35\x02\x02\u{e0}\u{dc}\x03\x02\x02\x02\u{e0}\u{dd}\ + \x03\x02\x02\x02\u{e0}\u{de}\x03\x02\x02\x02\u{e0}\u{df}\x03\x02\x02\x02\ + \u{e1}\u{e4}\x03\x02\x02\x02\u{e2}\u{e3}\x07\x21\x02\x02\u{e3}\u{e5}\x07\ + \x35\x02\x02\u{e4}\u{e2}\x03\x02\x02\x02\u{e4}\u{e5}\x03\x02\x02\x02\u{e5}\ + \x23\x03\x02\x02\x02\u{e6}\u{eb}\x05\x26\x14\x02\u{e7}\u{e8}\x09\x02\x02\ + \x02\u{e8}\u{ea}\x05\x26\x14\x02\u{e9}\u{e7}\x03\x02\x02\x02\u{ea}\u{ed}\ + \x03\x02\x02\x02\u{eb}\u{e9}\x03\x02\x02\x02\u{eb}\u{ec}\x03\x02\x02\x02\ + \u{ec}\x25\x03\x02\x02\x02\u{ed}\u{eb}\x03\x02\x02\x02\u{ee}\u{f3}\x05\x28\ + \x15\x02\u{ef}\u{f0}\x09\x03\x02\x02\u{f0}\u{f2}\x05\x28\x15\x02\u{f1}\u{ef}\ + \x03\x02\x02\x02\u{f2}\u{f5}\x03\x02\x02\x02\u{f3}\u{f1}\x03\x02\x02\x02\ + \u{f3}\u{f4}\x03\x02\x02\x02\u{f4}\x27\x03\x02\x02\x02\u{f5}\u{f3}\x03\x02\ + \x02\x02\u{f6}\u{103}\x05\x1c\x0f\x02\u{f7}\u{103}\x07\x35\x02\x02\u{f8}\ + \u{103}\x07\x36\x02\x02\u{f9}\u{103}\x07\x37\x02\x02\u{fa}\u{103}\x07\x38\ + \x02\x02\u{fb}\u{103}\x05\x2a\x16\x02\u{fc}\u{103}\x05\x3c\x1f\x02\u{fd}\ + \u{103}\x07\x34\x02\x02\u{fe}\u{ff}\x07\x04\x02\x02\u{ff}\u{100}\x05\x24\ + \x13\x02\u{100}\u{101}\x07\x05\x02\x02\u{101}\u{103}\x03\x02\x02\x02\u{102}\ + \u{f6}\x03\x02\x02\x02\u{102}\u{f7}\x03\x02\x02\x02\u{102}\u{f8}\x03\x02\ + \x02\x02\u{102}\u{f9}\x03\x02\x02\x02\u{102}\u{fa}\x03\x02\x02\x02\u{102}\ + \u{fb}\x03\x02\x02\x02\u{102}\u{fc}\x03\x02\x02\x02\u{102}\u{fd}\x03\x02\ + \x02\x02\u{102}\u{fe}\x03\x02\x02\x02\u{103}\x29\x03\x02\x02\x02\u{104}\ + \u{105}\x07\x35\x02\x02\u{105}\u{10a}\x07\x04\x02\x02\u{106}\u{108}\x07\ + \x1b\x02\x02\u{107}\u{106}\x03\x02\x02\x02\u{107}\u{108}\x03\x02\x02\x02\ + \u{108}\u{109}\x03\x02\x02\x02\u{109}\u{10b}\x05\x1e\x10\x02\u{10a}\u{107}\ + \x03\x02\x02\x02\u{10a}\u{10b}\x03\x02\x02\x02\u{10b}\u{10c}\x03\x02\x02\ + \x02\u{10c}\u{112}\x07\x05\x02\x02\u{10d}\u{10e}\x07\x28\x02\x02\u{10e}\ + \u{10f}\x07\x04\x02\x02\u{10f}\u{110}\x05\x10\x09\x02\u{110}\u{111}\x07\ + \x05\x02\x02\u{111}\u{113}\x03\x02\x02\x02\u{112}\u{10d}\x03\x02\x02\x02\ + \u{112}\u{113}\x03\x02\x02\x02\u{113}\x2b\x03\x02\x02\x02\u{114}\u{115}\ + \x09\x04\x02\x02\u{115}\u{118}\x07\x07\x02\x02\u{116}\u{119}\x05\x34\x1b\ + \x02\u{117}\u{119}\x05\x2e\x18\x02\u{118}\u{116}\x03\x02\x02\x02\u{118}\ + \u{117}\x03\x02\x02\x02\u{119}\x2d\x03\x02\x02\x02\u{11a}\u{11f}\x05\x30\ + \x19\x02\u{11b}\u{11c}\x07\x1a\x02\x02\u{11c}\u{11e}\x05\x30\x19\x02\u{11d}\ + \u{11b}\x03\x02\x02\x02\u{11e}\u{121}\x03\x02\x02\x02\u{11f}\u{11d}\x03\ + \x02\x02\x02\u{11f}\u{120}\x03\x02\x02\x02\u{120}\x2f\x03\x02\x02\x02\u{121}\ + \u{11f}\x03\x02\x02\x02\u{122}\u{127}\x05\x32\x1a\x02\u{123}\u{124}\x07\ + \x19\x02\x02\u{124}\u{126}\x05\x32\x1a\x02\u{125}\u{123}\x03\x02\x02\x02\ + \u{126}\u{129}\x03\x02\x02\x02\u{127}\u{125}\x03\x02\x02\x02\u{127}\u{128}\ + \x03\x02\x02\x02\u{128}\x31\x03\x02\x02\x02\u{129}\u{127}\x03\x02\x02\x02\ + \u{12a}\u{130}\x05\x34\x1b\x02\u{12b}\u{12c}\x07\x04\x02\x02\u{12c}\u{12d}\ + \x05\x2e\x18\x02\u{12d}\u{12e}\x07\x05\x02\x02\u{12e}\u{130}\x03\x02\x02\ + \x02\u{12f}\u{12a}\x03\x02\x02\x02\u{12f}\u{12b}\x03\x02\x02\x02\u{130}\ + \x33\x03\x02\x02\x02\u{131}\u{132}\x05\x24\x13\x02\u{132}\u{133}\x05\x44\ + \x23\x02\u{133}\u{134}\x05\x24\x13\x02\u{134}\u{167}\x03\x02\x02\x02\u{135}\ + \u{136}\x05\x1c\x0f\x02\u{136}\u{13d}\x05\x44\x23\x02\u{137}\u{13e}\x05\ + \x1c\x0f\x02\u{138}\u{13e}\x07\x38\x02\x02\u{139}\u{13e}\x07\x35\x02\x02\ + \u{13a}\u{13e}\x07\x36\x02\x02\u{13b}\u{13e}\x07\x37\x02\x02\u{13c}\u{13e}\ + \x07\x34\x02\x02\u{13d}\u{137}\x03\x02\x02\x02\u{13d}\u{138}\x03\x02\x02\ + \x02\u{13d}\u{139}\x03\x02\x02\x02\u{13d}\u{13a}\x03\x02\x02\x02\u{13d}\ + \u{13b}\x03\x02\x02\x02\u{13d}\u{13c}\x03\x02\x02\x02\u{13e}\u{167}\x03\ + \x02\x02\x02\u{13f}\u{140}\x07\x35\x02\x02\u{140}\u{147}\x05\x44\x23\x02\ + \u{141}\u{148}\x05\x1c\x0f\x02\u{142}\u{148}\x07\x38\x02\x02\u{143}\u{148}\ + \x07\x35\x02\x02\u{144}\u{148}\x07\x36\x02\x02\u{145}\u{148}\x07\x37\x02\ + \x02\u{146}\u{148}\x07\x34\x02\x02\u{147}\u{141}\x03\x02\x02\x02\u{147}\ + \u{142}\x03\x02\x02\x02\u{147}\u{143}\x03\x02\x02\x02\u{147}\u{144}\x03\ + \x02\x02\x02\u{147}\u{145}\x03\x02\x02\x02\u{147}\u{146}\x03\x02\x02\x02\ + \u{148}\u{167}\x03\x02\x02\x02\u{149}\u{14a}\x07\x34\x02\x02\u{14a}\u{151}\ + \x05\x44\x23\x02\u{14b}\u{152}\x05\x1c\x0f\x02\u{14c}\u{152}\x07\x38\x02\ + \x02\u{14d}\u{152}\x07\x35\x02\x02\u{14e}\u{152}\x07\x36\x02\x02\u{14f}\ + \u{152}\x07\x37\x02\x02\u{150}\u{152}\x07\x34\x02\x02\u{151}\u{14b}\x03\ + \x02\x02\x02\u{151}\u{14c}\x03\x02\x02\x02\u{151}\u{14d}\x03\x02\x02\x02\ + \u{151}\u{14e}\x03\x02\x02\x02\u{151}\u{14f}\x03\x02\x02\x02\u{151}\u{150}\ + \x03\x02\x02\x02\u{152}\u{167}\x03\x02\x02\x02\u{153}\u{155}\x05\x1c\x0f\ + \x02\u{154}\u{156}\x05\x42\x22\x02\u{155}\u{154}\x03\x02\x02\x02\u{155}\ + \u{156}\x03\x02\x02\x02\u{156}\u{167}\x03\x02\x02\x02\u{157}\u{159}\x07\ + \x35\x02\x02\u{158}\u{15a}\x05\x42\x22\x02\u{159}\u{158}\x03\x02\x02\x02\ + \u{159}\u{15a}\x03\x02\x02\x02\u{15a}\u{167}\x03\x02\x02\x02\u{15b}\u{15d}\ + \x07\x34\x02\x02\u{15c}\u{15e}\x05\x42\x22\x02\u{15d}\u{15c}\x03\x02\x02\ + \x02\u{15d}\u{15e}\x03\x02\x02\x02\u{15e}\u{167}\x03\x02\x02\x02\u{15f}\ + \u{167}\x07\x38\x02\x02\u{160}\u{167}\x07\x36\x02\x02\u{161}\u{167}\x07\ + \x37\x02\x02\u{162}\u{167}\x05\x0e\x08\x02\u{163}\u{167}\x05\x36\x1c\x02\ + \u{164}\u{167}\x05\x38\x1d\x02\u{165}\u{167}\x05\x3a\x1e\x02\u{166}\u{131}\ + \x03\x02\x02\x02\u{166}\u{135}\x03\x02\x02\x02\u{166}\u{13f}\x03\x02\x02\ + \x02\u{166}\u{149}\x03\x02\x02\x02\u{166}\u{153}\x03\x02\x02\x02\u{166}\ + \u{157}\x03\x02\x02\x02\u{166}\u{15b}\x03\x02\x02\x02\u{166}\u{15f}\x03\ + \x02\x02\x02\u{166}\u{160}\x03\x02\x02\x02\u{166}\u{161}\x03\x02\x02\x02\ + \u{166}\u{162}\x03\x02\x02\x02\u{166}\u{163}\x03\x02\x02\x02\u{166}\u{164}\ + \x03\x02\x02\x02\u{166}\u{165}\x03\x02\x02\x02\u{167}\x35\x03\x02\x02\x02\ + \u{168}\u{169}\x07\x1c\x02\x02\u{169}\u{16a}\x07\x04\x02\x02\u{16a}\u{16b}\ + \x05\x0c\x07\x02\u{16b}\u{16c}\x07\x05\x02\x02\u{16c}\x37\x03\x02\x02\x02\ + \u{16d}\u{171}\x05\x1c\x0f\x02\u{16e}\u{171}\x07\x35\x02\x02\u{16f}\u{171}\ + \x07\x34\x02\x02\u{170}\u{16d}\x03\x02\x02\x02\u{170}\u{16e}\x03\x02\x02\ + \x02\u{170}\u{16f}\x03\x02\x02\x02\u{171}\u{172}\x03\x02\x02\x02\u{172}\ + \u{174}\x07\x1e\x02\x02\u{173}\u{175}\x07\x1f\x02\x02\u{174}\u{173}\x03\ + \x02\x02\x02\u{174}\u{175}\x03\x02\x02\x02\u{175}\u{176}\x03\x02\x02\x02\ + \u{176}\u{177}\x07\x1d\x02\x02\u{177}\x39\x03\x02\x02\x02\u{178}\u{17c}\ + \x05\x1c\x0f\x02\u{179}\u{17c}\x07\x35\x02\x02\u{17a}\u{17c}\x07\x34\x02\ + \x02\u{17b}\u{178}\x03\x02\x02\x02\u{17b}\u{179}\x03\x02\x02\x02\u{17b}\ + \u{17a}\x03\x02\x02\x02\u{17c}\u{17d}\x03\x02\x02\x02\u{17d}\u{17e}\x07\ + \x20\x02\x02\u{17e}\u{181}\x07\x04\x02\x02\u{17f}\u{182}\x05\x0c\x07\x02\ + \u{180}\u{182}\x05\x1e\x10\x02\u{181}\u{17f}\x03\x02\x02\x02\u{181}\u{180}\ + \x03\x02\x02\x02\u{182}\u{183}\x03\x02\x02\x02\u{183}\u{184}\x07\x05\x02\ + \x02\u{184}\x3b\x03\x02\x02\x02\u{185}\u{187}\x07\x22\x02\x02\u{186}\u{188}\ + \x05\x3e\x20\x02\u{187}\u{186}\x03\x02\x02\x02\u{188}\u{189}\x03\x02\x02\ + \x02\u{189}\u{187}\x03\x02\x02\x02\u{189}\u{18a}\x03\x02\x02\x02\u{18a}\ + \u{18d}\x03\x02\x02\x02\u{18b}\u{18c}\x07\x25\x02\x02\u{18c}\u{18e}\x05\ + \x40\x21\x02\u{18d}\u{18b}\x03\x02\x02\x02\u{18d}\u{18e}\x03\x02\x02\x02\ + \u{18e}\u{18f}\x03\x02\x02\x02\u{18f}\u{190}\x07\x26\x02\x02\u{190}\x3d\ + \x03\x02\x02\x02\u{191}\u{192}\x07\x23\x02\x02\u{192}\u{193}\x05\x34\x1b\ + \x02\u{193}\u{194}\x07\x24\x02\x02\u{194}\u{195}\x05\x40\x21\x02\u{195}\ + \x3f\x03\x02\x02\x02\u{196}\u{19f}\x05\x24\x13\x02\u{197}\u{19f}\x05\x34\ + \x1b\x02\u{198}\u{19f}\x05\x1c\x0f\x02\u{199}\u{19f}\x07\x35\x02\x02\u{19a}\ + \u{19f}\x07\x36\x02\x02\u{19b}\u{19f}\x07\x37\x02\x02\u{19c}\u{19f}\x07\ + \x38\x02\x02\u{19d}\u{19f}\x07\x34\x02\x02\u{19e}\u{196}\x03\x02\x02\x02\ + \u{19e}\u{197}\x03\x02\x02\x02\u{19e}\u{198}\x03\x02\x02\x02\u{19e}\u{199}\ + \x03\x02\x02\x02\u{19e}\u{19a}\x03\x02\x02\x02\u{19e}\u{19b}\x03\x02\x02\ + \x02\u{19e}\u{19c}\x03\x02\x02\x02\u{19e}\u{19d}\x03\x02\x02\x02\u{19f}\ + \x41\x03\x02\x02\x02\u{1a0}\u{1a1}\x09\x05\x02\x02\u{1a1}\x43\x03\x02\x02\ + \x02\u{1a2}\u{1a3}\x09\x06\x02\x02\u{1a3}\x45\x03\x02\x02\x02\x2c\x49\x51\ + \x59\x6e\x74\x7f\u{8f}\u{92}\u{95}\u{a4}\u{ab}\u{ae}\u{b7}\u{c3}\u{ca}\u{da}\ + \u{e0}\u{e4}\u{eb}\u{f3}\u{102}\u{107}\u{10a}\u{112}\u{118}\u{11f}\u{127}\ + \u{12f}\u{13d}\u{147}\u{151}\u{155}\u{159}\u{15d}\u{166}\u{170}\u{174}\u{17b}\ + \u{181}\u{189}\u{18d}\u{19e}"; diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlvisitor.rs b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlvisitor.rs index d4f6c2fe..d427d58e 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlvisitor.rs +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/lqlvisitor.rs @@ -1,5 +1,5 @@ -#![allow(nonstandard_style)] // Generated from /home/user/DataProvider/Lql/Lql/Parsing/Lql.g4 by ANTLR 4.8 +// Generated from Lql.g4 by ANTLR 4.8 use super::lqlparser::*; use antlr_rust::tree::{ParseTreeVisitor, ParseTreeVisitorCompat}; @@ -24,6 +24,22 @@ pub trait LqlVisitor<'input>: ParseTreeVisitor<'input, LqlParserContextType> { self.visit_children(ctx) } + /** + * Visit a parse tree produced by {@link LqlParser#withStmt}. + * @param ctx the parse tree + */ + fn visit_withStmt(&mut self, ctx: &WithStmtContext<'input>) { + self.visit_children(ctx) + } + + /** + * Visit a parse tree produced by {@link LqlParser#cteDef}. + * @param ctx the parse tree + */ + fn visit_cteDef(&mut self, ctx: &CteDefContext<'input>) { + self.visit_children(ctx) + } + /** * Visit a parse tree produced by {@link LqlParser#letStmt}. * @param ctx the parse tree @@ -284,6 +300,22 @@ pub trait LqlVisitorCompat<'input>: self.visit_children(ctx) } + /** + * Visit a parse tree produced by {@link LqlParser#withStmt}. + * @param ctx the parse tree + */ + fn visit_withStmt(&mut self, ctx: &WithStmtContext<'input>) -> Self::Return { + self.visit_children(ctx) + } + + /** + * Visit a parse tree produced by {@link LqlParser#cteDef}. + * @param ctx the parse tree + */ + fn visit_cteDef(&mut self, ctx: &CteDefContext<'input>) -> Self::Return { + self.visit_children(ctx) + } + /** * Visit a parse tree produced by {@link LqlParser#letStmt}. * @param ctx the parse tree @@ -539,6 +571,16 @@ where *::temp_result(self) = result; } + fn visit_withStmt(&mut self, ctx: &WithStmtContext<'input>) { + let result = ::visit_withStmt(self, ctx); + *::temp_result(self) = result; + } + + fn visit_cteDef(&mut self, ctx: &CteDefContext<'input>) { + let result = ::visit_cteDef(self, ctx); + *::temp_result(self) = result; + } + fn visit_letStmt(&mut self, ctx: &LetStmtContext<'input>) { let result = ::visit_letStmt(self, ctx); *::temp_result(self) = result; diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/mod.rs b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/mod.rs index 1e1d894b..1bae000c 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/generated/mod.rs +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/generated/mod.rs @@ -4,6 +4,8 @@ #![allow(unused_variables)] #![allow(unused_mut)] #![allow(unused_braces)] +// The ANTLR Rust target wraps some generated `while` conditions in parentheses. +#![allow(unused_parens)] #![allow(clippy::all)] pub mod lqllexer; diff --git a/Lql/lql-lsp-rust/crates/lql-parser/src/tokens.rs b/Lql/lql-lsp-rust/crates/lql-parser/src/tokens.rs index bd920543..d181ba34 100644 --- a/Lql/lql-lsp-rust/crates/lql-parser/src/tokens.rs +++ b/Lql/lql-lsp-rust/crates/lql-parser/src/tokens.rs @@ -11,15 +11,15 @@ use crate::LqlLexer; /// with readable names so downstream crates don't depend on T__N magic numbers. pub mod token_types { /// `|>` pipe operator - pub const PIPE: isize = 3; // T__2 + pub const PIPE: isize = super::super::generated::lqllexer::T__5; /// `(` - pub const OPEN_PAREN: isize = 4; // T__3 + pub const OPEN_PAREN: isize = super::super::generated::lqllexer::T__1; /// `)` - pub const CLOSE_PAREN: isize = 5; // T__4 + pub const CLOSE_PAREN: isize = super::super::generated::lqllexer::T__2; /// `,` - pub const COMMA: isize = 6; // T__5 + pub const COMMA: isize = super::super::generated::lqllexer::T__0; /// `.` - pub const DOT: isize = 9; // T__8 + pub const DOT: isize = super::super::generated::lqllexer::T__8; /// `--` line comment pub const COMMENT: isize = super::super::generated::lqllexer::COMMENT; /// Whitespace diff --git a/Lql/lql-lsp-rust/setup-ai.sh b/Lql/lql-lsp-rust/setup-ai.sh old mode 100755 new mode 100644 diff --git a/Lql/lql-lsp-rust/test-coverage.sh b/Lql/lql-lsp-rust/test-coverage.sh old mode 100755 new mode 100644 diff --git a/Makefile b/Makefile index 48ff8623..b9cf56a1 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,23 @@ -# agent-pmo:74cf183 -# ============================================================================= +# agent-pmo:795a9c2 # Standard Makefile — Nimblesite.DataProvider.Core # Cross-platform: Linux, macOS, Windows (via GNU Make) # All targets are language-agnostic. Add language-specific helpers below. -# ============================================================================= -.PHONY: build test lint fmt clean ci setup check coverage vsix help +.PHONY: build test lint fmt clean ci setup check coverage vsix rebuild-install-vsix clinical help + +# Bound build resources so compiler/analyzer work cannot exhaust the editor host. +export DOTNET_PROCESSOR_COUNT ?= 2 +export DOTNET_GCHeapHardLimit ?= 0x80000000 +export DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER = 1 +export CARGO_BUILD_JOBS ?= 2 +DOTNET_BUILD_FLAGS = --disable-build-servers -m:1 -p:BuildInParallel=false -p:UseSharedCompilation=false + +# Installed VS Code extension id (publisher.name from Lql/LqlExtension/package.json) +VSIX_EXT_ID = lql-team.lql-language-support -# ----------------------------------------------------------------------------- # OS Detection — portable commands for Linux, macOS, and Windows # On Windows, run via GNU Make with PowerShell (e.g., make from Git Bash or # choco install make). The $(OS) variable is set to "Windows_NT" automatically. -# ----------------------------------------------------------------------------- ifeq ($(OS),Windows_NT) SHELL := powershell.exe .SHELLFLAGS := -NoProfile -Command @@ -24,6 +30,17 @@ else SHELL := /bin/bash RM = rm -rf MKDIR = mkdir -p + ifeq ($(shell uname -s),Darwin) + # Implements [CI-TIMEOUT]: macOS native FSEvents can stall test-host startup. + # Polling preserves configuration change notifications without that native wait. + export DOTNET_USE_POLLING_FILE_WATCHER ?= 1 + # Keep debug information in object files; dsymutil also scans ancestors. + export RUSTFLAGS := $(RUSTFLAGS) -Csplit-debuginfo=unpacked + ifneq ($(wildcard $(HOME)/.colima/default/docker.sock),) + # Testcontainers Rust needs the explicit socket when Colima is installed. + _test_rust: export DOCKER_HOST ?= unix://$(HOME)/.colima/default/docker.sock + endif + endif endif # All .NET test projects (one per line for readability) @@ -41,14 +58,12 @@ DOTNET_TEST_PROJECTS = \ Reporting/Nimblesite.Reporting.Tests \ Reporting/Nimblesite.Reporting.Integration.Tests -# ============================================================================= # Standard Targets # # Portfolio-wide uniform interface — these 7 targets exist in every repo and # their names never change. See REPO-STANDARDS-SPEC [MAKE-TARGETS]. # Do NOT add extra public targets here — put them in the Repo-Specific # Targets section below. -# ============================================================================= ## build: Compile/assemble all artifacts build: @@ -59,6 +74,9 @@ build: ## See REPO-STANDARDS-SPEC [TEST-RULES]. test: @echo "==> Testing..." + python3 tools/test-version-stamp.py + npm ci --prefix Website --ignore-scripts --no-audit --no-fund + python3 Website/scripts/test-api-docs.py $(MAKE) _test ## lint: Run all linters/analyzers (read-only). Does NOT format. @@ -85,14 +103,10 @@ setup: $(MAKE) _setup @echo "==> Setup complete. Run 'make ci' to validate." -# ============================================================================= -# ----------------------------------------------------------------------------- # Repo-Specific Targets (NOT part of the portfolio standard 7) # # Add helpers unique to this repo below. They MUST NOT shadow any of the 7 # standard targets above. See REPO-STANDARDS-SPEC [MAKE-TARGETS]. -# ----------------------------------------------------------------------------- -# ============================================================================= ## check: lint + test (pre-commit shortcut) check: lint test @@ -102,14 +116,36 @@ coverage: @echo "==> Coverage report..." $(MAKE) _coverage +## aot: Native AOT publish of the DataProviderMigrate CLI + native smoke test ([MIG-AOT-CI]) +aot: + @echo "==> Native AOT publish + smoke test (DataProviderMigrate)..." + $(MAKE) _aot_dotnet + ## vsix: Build Rust LSP (release), compile & package the VS Code extension (.vsix), and install it vsix: @echo "==> Building and packaging VSIX..." bash Lql/lql-lsp-rust/build-vsix.sh -# ============================================================================= +## clinical: Build + test ClinicalCoding (../ClinicalCoding) against this checkout via a local +## NuGet feed ([CI-CLINICAL-LOCAL]). Override CLINICAL_DIR / CLINICAL_TARGETS as needed. +clinical: + bash tools/clinical-local.sh + +## rebuild-install-vsix: Full clean cycle — uninstall, clean, rebuild, package, install ([MAKE-IDE-EXT]) +rebuild-install-vsix: _vsix_uninstall _vsix_clean vsix + +# Uninstall the currently-installed LQL extension (ignore if absent). +_vsix_uninstall: + @echo "==> Uninstalling $(VSIX_EXT_ID) (if present)..." + -code --uninstall-extension $(VSIX_EXT_ID) + +# Remove the previously packaged .vsix and the compiled extension output. +_vsix_clean: + @echo "==> Cleaning packaged VSIX + extension build output..." + $(RM) Lql/LqlExtension/out + -$(RM) Lql/LqlExtension/*.vsix + # LANGUAGE-SPECIFIC IMPLEMENTATIONS -# ============================================================================= _build: _build_dotnet _build_rust _build_ts @@ -128,9 +164,7 @@ _coverage: _coverage_dotnet _setup: _setup_dotnet _setup_ts -# ============================================================================= # COVERAGE ENFORCEMENT (shared shell logic) -# ============================================================================= # Each test target collects coverage, compares against coverage-thresholds.json, # fails hard if below, and ratchets up if above. # @@ -139,11 +173,10 @@ _setup: _setup_dotnet _setup_ts # # The SRC_KEY mapping converts test project paths -> source project keys. # CI calls these same make targets — no duplication. -# ============================================================================= # --- C#/.NET --- _build_dotnet: - dotnet build DataProvider.sln --configuration Release + dotnet build DataProvider.sln --configuration Release $(DOTNET_BUILD_FLAGS) _test_dotnet: @for test_proj in $(DOTNET_TEST_PROJECTS); do \ @@ -171,14 +204,14 @@ _test_dotnet: echo "============================================================"; \ rm -rf "$$test_proj/TestResults"; \ if [ -n "$$INCLUDE" ]; then \ - dotnet test "$$test_proj" --configuration Release \ + dotnet test "$$test_proj" --configuration Release $(DOTNET_BUILD_FLAGS) \ --settings coverlet.runsettings \ --collect:"XPlat Code Coverage" \ --results-directory "$$test_proj/TestResults" \ --verbosity normal \ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="$$INCLUDE"; \ else \ - dotnet test "$$test_proj" --configuration Release \ + dotnet test "$$test_proj" --configuration Release $(DOTNET_BUILD_FLAGS) \ --settings coverlet.runsettings \ --collect:"XPlat Code Coverage" \ --results-directory "$$test_proj/TestResults" \ @@ -219,7 +252,7 @@ _test_dotnet: echo "==> All .NET test projects passed coverage thresholds." _lint_dotnet: - dotnet build DataProvider.sln --configuration Release + dotnet build DataProvider.sln --configuration Release $(DOTNET_BUILD_FLAGS) dotnet csharpier check . _fmt_dotnet: @@ -253,17 +286,52 @@ _setup_dotnet: dotnet restore dotnet tool restore +# Native AOT publish of the migration CLI, then run the published native binary +# as a black-box smoke test against a throwaway SQLite database. Implements +# [MIG-AOT-CI]. RID auto-detects per platform; override with `make aot RID=...`. +AOT_PROJ := Migration/DataProviderMigrate/DataProviderMigrate.csproj +ifeq ($(OS),Windows_NT) +RID ?= win-x64 +AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate.exe +else ifeq ($(shell uname -s),Darwin) +ifeq ($(shell uname -m),arm64) +RID ?= osx-arm64 +else +RID ?= osx-x64 +endif +AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate +else +RID ?= linux-x64 +AOT_EXE := Migration/DataProviderMigrate/bin/Release/net9.0/$(RID)/publish/DataProviderMigrate +endif + +_aot_dotnet: + @echo "==> Publishing Native AOT ($(RID))..." + dotnet publish $(AOT_PROJ) -c Release -r $(RID) -p:PublishAot=true --self-contained $(DOTNET_BUILD_FLAGS) + @echo "==> Native smoke test: migrate example schema to SQLite..." + $(AOT_EXE) migrate --schema Migration/DataProviderMigrate/example-schema.yaml \ + --output $(AOT_SMOKE_DB) --provider sqlite + @echo "==> Native AOT smoke test passed." + +# Throwaway SQLite path for the smoke test (TMPDIR-aware, cleaned each run). +ifeq ($(OS),Windows_NT) +AOT_SMOKE_DB := $(TEMP)\dataprovider_aot_smoke.db +else +AOT_SMOKE_DB := $(shell printf '%s' "$${TMPDIR:-/tmp}")dataprovider_aot_smoke.db +endif + # --- RUST (LQL LSP) --- _build_rust: cd Lql/lql-lsp-rust && cargo build --release +# Implements [CI-RUST-COVERAGE]: use the same instrumentation on every platform. _test_rust: @THRESHOLD=$$(jq -r '.projects["Lql/lql-lsp-rust"].threshold // .default_threshold' coverage-thresholds.json); \ echo ""; \ echo "============================================================"; \ echo "==> Testing Lql/lql-lsp-rust (threshold: $$THRESHOLD%)"; \ echo "============================================================"; \ - cd Lql/lql-lsp-rust && cargo tarpaulin --workspace --skip-clean \ + cd Lql/lql-lsp-rust && cargo tarpaulin --engine llvm --workspace --skip-clean \ --exclude-files 'crates/lql-parser/src/generated/*' \ --exclude-files 'crates/lql-lsp/tests/*' \ 2>&1 | tee /tmp/_dp_tarpaulin_out.txt; \ @@ -376,9 +444,7 @@ _clean_ts: _setup_ts: cd Lql/LqlExtension && npm install --no-audit --no-fund -# ============================================================================= # HELP -# ============================================================================= help: @echo "Standard targets (portfolio-wide):" @echo " build - Compile/assemble all artifacts" @@ -393,3 +459,4 @@ help: @echo " check - lint + test (pre-commit shortcut)" @echo " coverage - Generate and open HTML coverage report" @echo " vsix - Build LSP + compile & package VS Code extension (.vsix)" + @echo " clinical - Test ../ClinicalCoding against this checkout (local NuGet feed)" diff --git a/Migration/DataProviderMigrate/DataProviderMigrate.csproj b/Migration/DataProviderMigrate/DataProviderMigrate.csproj index c82e0888..f4c6c520 100644 --- a/Migration/DataProviderMigrate/DataProviderMigrate.csproj +++ b/Migration/DataProviderMigrate/DataProviderMigrate.csproj @@ -7,13 +7,34 @@ DataProviderMigrate true DataProviderMigrate - CLI tool for DataProvider database schema migrations from YAML (SQLite + PostgreSQL). + CLI tool for DataProvider database schema migrations from YAML (SQLite, PostgreSQL, SQL Server). ChristianFindlay Nimblesite MIT https://github.com/Nimblesite/DataProvider git - dataprovider;migration;schema;yaml;sqlite;postgres;dotnet-tool + dataprovider;migration;schema;yaml;sqlite;postgres;sqlserver;dotnet-tool + + + + + $(DefineConstants);AOT + true + true + true + true + + false + false @@ -25,5 +46,6 @@ + diff --git a/Migration/DataProviderMigrate/Program.Export.cs b/Migration/DataProviderMigrate/Program.Export.cs new file mode 100644 index 00000000..753607af --- /dev/null +++ b/Migration/DataProviderMigrate/Program.Export.cs @@ -0,0 +1,230 @@ +#if !AOT +using System.Reflection; +using Nimblesite.DataProvider.Migration.Core; + +namespace DataProviderMigrate; + +// Implements [MIG-AOT-EXPORT]. The export command loads an arbitrary external +// assembly via reflection (Assembly.LoadFrom + GetType + reflected member +// invoke). That is fundamentally incompatible with a self-contained Native AOT +// binary, so this whole file is excluded from the AOT build. The native binary +// ships migrate only; export remains in the managed `dotnet tool`. +public static partial class Program +{ + private static int RunExport(string[] args) + { + var parseResult = ParseExportArguments(args); + + return parseResult switch + { + ExportParseResult.Success success => ExecuteExport(success), + ExportParseResult.Failure failure => ShowExportError(failure), + ExportParseResult.HelpRequested => ShowExportUsage(), + }; + } + + private static int ExecuteExport(ExportParseResult.Success args) + { + Console.WriteLine( + $""" + DataProviderMigrate - Export C# Schema to YAML + Assembly: {args.AssemblyPath} + Type: {args.TypeName} + Output: {args.OutputPath} + """ + ); + + if (!File.Exists(args.AssemblyPath)) + { + Console.WriteLine($"Error: Assembly not found: {args.AssemblyPath}"); + return 1; + } + + try + { + var assembly = Assembly.LoadFrom(args.AssemblyPath); + var schemaType = assembly.GetType(args.TypeName); + + if (schemaType is null) + { + Console.WriteLine($"Error: Type '{args.TypeName}' not found in assembly"); + return 1; + } + + var schema = GetSchemaDefinition(schemaType); + + if (schema is null) + { + Console.WriteLine( + $"Error: Could not get SchemaDefinition from type '{args.TypeName}'\n Expected: static property 'Definition' or static method 'Build()' returning SchemaDefinition" + ); + return 1; + } + + var directory = Path.GetDirectoryName(args.OutputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + SchemaYamlSerializer.ToYamlFile(schema, args.OutputPath); + Console.WriteLine( + $"Successfully exported schema '{schema.Name}' with {schema.Tables.Count} tables\n Output: {args.OutputPath}" + ); + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex}"); + return 1; + } + } + + private static SchemaDefinition? GetSchemaDefinition(Type schemaType) + { + var definitionProp = schemaType.GetProperty( + "Definition", + BindingFlags.Public | BindingFlags.Static + ); + + if (definitionProp?.GetValue(null) is SchemaDefinition defFromProp) + { + return defFromProp; + } + + var buildMethod = schemaType.GetMethod( + "Build", + BindingFlags.Public | BindingFlags.Static, + Type.EmptyTypes + ); + + if (buildMethod?.Invoke(null, null) is SchemaDefinition defFromMethod) + { + return defFromMethod; + } + + return null; + } + + private static int ShowExportError(ExportParseResult.Failure failure) + { + Console.WriteLine($"Error: {failure.Message}\n"); + return ShowExportUsage(); + } + + private static int ShowExportUsage() + { + Console.WriteLine( + """ + Usage: DataProviderMigrate export [options] + + Options: + --assembly, -a Path to compiled assembly containing schema class (required) + --type, -t Fully qualified type name of schema class (required) + --output, -o Path to output YAML file (required) + + Examples: + DataProviderMigrate export -a bin/Debug/net10.0/MyProject.dll -t MyNamespace.MySchema -o schema.yaml + + Schema Class Requirements: + - Static property 'Definition' returning SchemaDefinition, OR + - Static method 'Build()' returning SchemaDefinition + """ + ); + return 1; + } + + private static ExportParseResult ParseExportArguments(string[] args) + { + string? assemblyPath = null; + string? typeName = null; + string? outputPath = null; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + switch (arg) + { + case "--assembly" or "-a": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure("--assembly requires a path argument"); + } + + assemblyPath = args[++i]; + break; + + case "--type" + or "-t": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure( + "--type requires a type name argument" + ); + } + + typeName = args[++i]; + break; + + case "--output" + or "-o": + if (i + 1 >= args.Length) + { + return new ExportParseResult.Failure("--output requires a path argument"); + } + + outputPath = args[++i]; + break; + + case "--help" + or "-h": + return new ExportParseResult.HelpRequested(); + + default: + if (arg.StartsWith('-')) + { + return new ExportParseResult.Failure($"Unknown option: {arg}"); + } + + break; + } + } + + if (string.IsNullOrEmpty(assemblyPath)) + { + return new ExportParseResult.Failure("--assembly is required"); + } + + if (string.IsNullOrEmpty(typeName)) + { + return new ExportParseResult.Failure("--type is required"); + } + + if (string.IsNullOrEmpty(outputPath)) + { + return new ExportParseResult.Failure("--output is required"); + } + + return new ExportParseResult.Success(assemblyPath, typeName, outputPath); + } +} + +/// +/// Export subcommand argument parsing result. +/// +public abstract record ExportParseResult +{ + private ExportParseResult() { } + + /// Successfully parsed export arguments. + public sealed record Success(string AssemblyPath, string TypeName, string OutputPath) + : ExportParseResult; + + /// Parse error. + public sealed record Failure(string Message) : ExportParseResult; + + /// Help requested. + public sealed record HelpRequested : ExportParseResult; +} +#endif diff --git a/Migration/DataProviderMigrate/Program.cs b/Migration/DataProviderMigrate/Program.cs index 50597063..b2c8bba6 100644 --- a/Migration/DataProviderMigrate/Program.cs +++ b/Migration/DataProviderMigrate/Program.cs @@ -1,9 +1,10 @@ using System.Collections.Immutable; -using System.Reflection; +using Microsoft.Data.SqlClient; using Microsoft.Data.Sqlite; using Nimblesite.DataProvider.Migration.Core; using Nimblesite.DataProvider.Migration.Postgres; using Nimblesite.DataProvider.Migration.SQLite; +using Nimblesite.DataProvider.Migration.SqlServer; using Npgsql; using SchemaIntegrityResultError = Outcome.Result< System.Collections.Immutable.ImmutableArray, @@ -26,12 +27,12 @@ namespace DataProviderMigrate; /// CLI tool for database schema operations: migrate from YAML and export C# schemas to YAML. /// This is the ONLY canonical tool for database creation - all projects MUST use this. /// -public static class Program +public static partial class Program { /// /// Entry point - dispatches to migrate or export subcommand. /// Usage: - /// migrate: dotnet run -- migrate --schema path/to/schema.yaml --output path/to/database.db --provider [sqlite|postgres] + /// migrate: dotnet run -- migrate --schema path/to/schema.yaml --output path/to/database.db --provider [sqlite|postgres|sqlserver] /// export: dotnet run -- export --assembly path/to/assembly.dll --type Namespace.SchemaClass --output path/to/schema.yaml /// public static int Main(string[] args) @@ -53,6 +54,22 @@ _ when command.StartsWith('-') => RunMigrate(args), // backwards compat: no subc }; } +#if AOT + // Implements [MIG-AOT-EXPORT]: the export command loads an arbitrary external + // assembly via reflection (Assembly.LoadFrom), which a self-contained Native + // AOT binary cannot do. The native build ships migrate only; export remains in + // the managed `dotnet tool`. + private static int RunExport(string[] args) + { + Console.WriteLine( + "Error: the 'export' command is not available in the native (AOT) build.\n" + + "It loads an external assembly via reflection, which Native AOT cannot do.\n" + + "Use the managed dotnet tool 'DataProviderMigrate export ...' instead." + ); + return 1; + } +#endif + private static int RunMigrate(string[] args) { var parseResult = ParseMigrateArguments(args); @@ -65,27 +82,21 @@ private static int RunMigrate(string[] args) }; } - private static int RunExport(string[] args) - { - var parseResult = ParseExportArguments(args); - - return parseResult switch - { - ExportParseResult.Success success => ExecuteExport(success), - ExportParseResult.Failure failure => ShowExportError(failure), - ExportParseResult.HelpRequested => ShowExportUsage(), - }; - } - // ── Migrate ────────────────────────────────────────────────────────── private static int ExecuteMigration(MigrateParseResult.Success args) { + // Implements [MIG-CLI-COMMANDS]: connection descriptions must not expose credentials. + var outputDescription = + args.Provider.Equals("postgres", StringComparison.OrdinalIgnoreCase) + ? "" + : IsSqlServerProvider(provider: args.Provider) ? "" + : args.OutputPath; Console.WriteLine( $""" DataProviderMigrate - Database Schema Tool Schema: {args.SchemaPath} - Output: {args.OutputPath} + Output: {outputDescription} Provider: {args.Provider} """ ); @@ -111,8 +122,8 @@ private static int ExecuteMigration(MigrateParseResult.Success args) if (IsSqlServerProvider(args.Provider) && SchemaContainsRls(schema)) { - // Implements [RLS-MSSQL]. The SQL Server migration package does - // not exist yet, so RLS targeting SQL Server must fail closed. + // Implements [RLS-MSSQL]. Native SQL Server RLS migrations are + // not implemented, so RLS targeting SQL Server must fail closed. Console.WriteLine(MigrationError.RlsMssqlUnsupported().Message); return 1; } @@ -131,6 +142,12 @@ private static int ExecuteMigration(MigrateParseResult.Success args) args.AllowDestructive, args.Phase ), + "sqlserver" or "mssql" => MigrateSqlServerDatabase( + schema, + args.OutputPath, + args.AllowDestructive, + args.Phase + ), _ => ShowProviderError(args.Provider), }; } @@ -168,12 +185,13 @@ MigratePhase phase phase, () => SqliteSchemaInspector.Inspect(connection), ops => - MigrationRunner.Apply( + SqliteMigrationApplier.Apply( connection, + schema, ops, - SqliteDdlGenerator.Generate, new MigrationOptions { AllowDestructive = allowDestructive } - ) + ), + (_, desired) => SqliteSchemaNormalizer.Normalize(desired) ); } catch (Exception ex) @@ -206,8 +224,22 @@ MigratePhase phase connection, ops, PostgresDdlGenerator.Generate, - new MigrationOptions { AllowDestructive = allowDestructive } - ) + new MigrationOptions { AllowDestructive = allowDestructive }, + verifyBeforeCommit: () => + VerifySchemaIntegrity( + schema, + phase, + () => PostgresSchemaInspector.Inspect(connection, "public"), + (live, target) => + PostgresPolicyCatalogNormalizer.Normalize( + connection, + live, + target + ) + ) == 0 + ), + (live, target) => + PostgresPolicyCatalogNormalizer.Normalize(connection, live, target) ); } catch (Exception ex) @@ -217,6 +249,42 @@ MigratePhase phase } } + // Implements [MIG-SQLSERVER]. + private static int MigrateSqlServerDatabase( + SchemaDefinition schema, + string connectionString, + bool allowDestructive, + MigratePhase phase + ) + { + try + { + using var connection = new SqlConnection(connectionString); + connection.Open(); + Console.WriteLine("Connected to SQL Server database"); + + return ApplyDiff( + schema, + allowDestructive, + phase, + () => SqlServerSchemaInspector.Inspect(connection), + ops => + MigrationRunner.Apply( + connection, + ops, + SqlServerDdlGenerator.Generate, + new MigrationOptions { AllowDestructive = allowDestructive } + ), + (_, desired) => SqlServerSchemaNormalizer.Normalize(desired) + ); + } + catch (Exception ex) + { + Console.WriteLine($"Error: SQL Server connection/migration failed: {ex}"); + return 1; + } + } + /// /// Inspect → diff → filter → apply pipeline shared between SQLite and /// Postgres. filters operations so callers can @@ -230,7 +298,12 @@ private static int ApplyDiff( bool allowDestructive, MigratePhase phase, Func> inspect, - Func, Outcome.Result> apply + Func, Outcome.Result> apply, + Func< + SchemaDefinition, + SchemaDefinition, + Outcome.Result + >? normalize = null ) { var inspectResult = inspect(); @@ -252,7 +325,12 @@ is Outcome.Result.Error< >)inspectResult ).Value; - var diff = SchemaDiff.Calculate(current, schema, allowDestructive); + var comparisonSchema = NormalizeDesired(current, schema, normalize); + if (comparisonSchema is null) + { + return 1; + } + var diff = SchemaDiff.Calculate(current, comparisonSchema, allowDestructive); if ( diff is Outcome.Result, MigrationError>.Error< @@ -282,7 +360,12 @@ is Outcome.Result, MigrationError>.Error< // up-to-date message only on success so the CLI never claims success // ahead of a failing post-check. Console.WriteLine("No operations to apply — running schema integrity check"); - var verifyExit = VerifySchemaIntegrity(schema: schema, phase: phase, inspect: inspect); + var verifyExit = VerifySchemaIntegrity( + schema: schema, + phase: phase, + inspect: inspect, + normalize: normalize + ); if (verifyExit == 0) { Console.WriteLine( @@ -312,13 +395,23 @@ applyResult is Outcome.Result.Error } Console.WriteLine("Migration completed successfully"); - return VerifySchemaIntegrity(schema: schema, phase: phase, inspect: inspect); + return VerifySchemaIntegrity( + schema: schema, + phase: phase, + inspect: inspect, + normalize: normalize + ); } private static int VerifySchemaIntegrity( SchemaDefinition schema, MigratePhase phase, - Func> inspect + Func> inspect, + Func< + SchemaDefinition, + SchemaDefinition, + Outcome.Result + >? normalize = null ) { var inspectResult = inspect(); @@ -340,9 +433,14 @@ is Outcome.Result.Error< MigrationError >)inspectResult ).Value; + var comparisonSchema = NormalizeDesired(live, schema, normalize); + if (comparisonSchema is null) + { + return 1; + } var verification = SchemaIntegrityVerifier.Verify( live: live, - desired: schema, + desired: comparisonSchema, includeSupportObjects: true, includeRls: phase != MigratePhase.Structural ); @@ -354,6 +452,42 @@ is Outcome.Result.Error< }; } + private static SchemaDefinition? NormalizeDesired( + SchemaDefinition live, + SchemaDefinition desired, + Func< + SchemaDefinition, + SchemaDefinition, + Outcome.Result + >? normalize + ) + { + if (normalize is null) + { + return desired; + } + var result = normalize(live, desired); + if ( + result + is Outcome.Result.Error< + SchemaDefinition, + MigrationError + > error + ) + { + Console.WriteLine($"Error: policy catalog normalization failed: {error.Value}"); + return null; + } + return + result + is Outcome.Result.Ok< + SchemaDefinition, + MigrationError + > ok + ? ok.Value + : null; + } + private static int WriteIntegrityResult(ImmutableArray mismatches) { if (mismatches.Length == 0) @@ -401,6 +535,8 @@ private static bool IsRlsOperation(SchemaOperation op) => is EnableRlsOperation or EnableForceRlsOperation or CreateRlsPolicyOperation + or AlterRlsPolicyOperation + or ReplaceRlsPolicyOperation or DropRlsPolicyOperation or DisableRlsOperation or DisableForceRlsOperation; @@ -408,96 +544,11 @@ or DisableRlsOperation private static int ShowProviderError(string provider) { Console.WriteLine( - $"Error: Unknown provider '{provider}'\nValid providers: sqlite, postgres" + $"Error: Unknown provider '{provider}'\nValid providers: sqlite, postgres, sqlserver" ); return 1; } - // ── Export ──────────────────────────────────────────────────────────── - - private static int ExecuteExport(ExportParseResult.Success args) - { - Console.WriteLine( - $""" - DataProviderMigrate - Export C# Schema to YAML - Assembly: {args.AssemblyPath} - Type: {args.TypeName} - Output: {args.OutputPath} - """ - ); - - if (!File.Exists(args.AssemblyPath)) - { - Console.WriteLine($"Error: Assembly not found: {args.AssemblyPath}"); - return 1; - } - - try - { - var assembly = Assembly.LoadFrom(args.AssemblyPath); - var schemaType = assembly.GetType(args.TypeName); - - if (schemaType is null) - { - Console.WriteLine($"Error: Type '{args.TypeName}' not found in assembly"); - return 1; - } - - var schema = GetSchemaDefinition(schemaType); - - if (schema is null) - { - Console.WriteLine( - $"Error: Could not get SchemaDefinition from type '{args.TypeName}'\n Expected: static property 'Definition' or static method 'Build()' returning SchemaDefinition" - ); - return 1; - } - - var directory = Path.GetDirectoryName(args.OutputPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - SchemaYamlSerializer.ToYamlFile(schema, args.OutputPath); - Console.WriteLine( - $"Successfully exported schema '{schema.Name}' with {schema.Tables.Count} tables\n Output: {args.OutputPath}" - ); - return 0; - } - catch (Exception ex) - { - Console.WriteLine($"Error: {ex}"); - return 1; - } - } - - private static SchemaDefinition? GetSchemaDefinition(Type schemaType) - { - var definitionProp = schemaType.GetProperty( - "Definition", - BindingFlags.Public | BindingFlags.Static - ); - - if (definitionProp?.GetValue(null) is SchemaDefinition defFromProp) - { - return defFromProp; - } - - var buildMethod = schemaType.GetMethod( - "Build", - BindingFlags.Public | BindingFlags.Static, - Type.EmptyTypes - ); - - if (buildMethod?.Invoke(null, null) is SchemaDefinition defFromMethod) - { - return defFromMethod; - } - - return null; - } - // ── Usage / Errors ─────────────────────────────────────────────────── private static int ShowTopLevelUsage() @@ -511,7 +562,7 @@ migrate Create database from YAML schema definition export Export C# schema class to YAML file Usage: - DataProviderMigrate migrate --schema schema.yaml --output database.db [--provider sqlite|postgres] + DataProviderMigrate migrate --schema schema.yaml --output database.db [--provider sqlite|postgres|sqlserver] DataProviderMigrate export --assembly assembly.dll --type Namespace.SchemaClass --output schema.yaml Run 'DataProviderMigrate --help' for command-specific options. @@ -540,8 +591,8 @@ private static int ShowMigrateUsage() Options: --schema, -s Path to YAML schema definition file (required) - --output, -o Path to output database file (SQLite) or connection string (Postgres) - --provider, -p Database provider: sqlite or postgres (default: sqlite) + --output, -o Path to output database file (SQLite) or connection string (Postgres, SQL Server) + --provider, -p Database provider: sqlite, postgres or sqlserver (default: sqlite) --allow-destructive Permit DROP/DISABLE operations (drift cleanup, FORCE removal, policy drops). Off by default for safety. --phase Operations to apply: all (default), structural, rls. @@ -563,34 +614,6 @@ DataProviderMigrate migrate --schema schema.yaml --output "$PG_URL" --provider p return 1; } - private static int ShowExportError(ExportParseResult.Failure failure) - { - Console.WriteLine($"Error: {failure.Message}\n"); - return ShowExportUsage(); - } - - private static int ShowExportUsage() - { - Console.WriteLine( - """ - Usage: DataProviderMigrate export [options] - - Options: - --assembly, -a Path to compiled assembly containing schema class (required) - --type, -t Fully qualified type name of schema class (required) - --output, -o Path to output YAML file (required) - - Examples: - DataProviderMigrate export -a bin/Debug/net10.0/MyProject.dll -t MyNamespace.MySchema -o schema.yaml - - Schema Class Requirements: - - Static property 'Definition' returning SchemaDefinition, OR - - Static method 'Build()' returning SchemaDefinition - """ - ); - return 1; - } - // ── Argument Parsing ───────────────────────────────────────────────── private static MigrateParseResult ParseMigrateArguments(string[] args) @@ -631,7 +654,7 @@ private static MigrateParseResult ParseMigrateArguments(string[] args) if (i + 1 >= args.Length) { return new MigrateParseResult.Failure( - "--provider requires an argument (sqlite or postgres)" + "--provider requires an argument (sqlite, postgres or sqlserver)" ); } @@ -697,81 +720,6 @@ private static MigrateParseResult ParseMigrateArguments(string[] args) phase ); } - - private static ExportParseResult ParseExportArguments(string[] args) - { - string? assemblyPath = null; - string? typeName = null; - string? outputPath = null; - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - - switch (arg) - { - case "--assembly" or "-a": - if (i + 1 >= args.Length) - { - return new ExportParseResult.Failure("--assembly requires a path argument"); - } - - assemblyPath = args[++i]; - break; - - case "--type" - or "-t": - if (i + 1 >= args.Length) - { - return new ExportParseResult.Failure( - "--type requires a type name argument" - ); - } - - typeName = args[++i]; - break; - - case "--output" - or "-o": - if (i + 1 >= args.Length) - { - return new ExportParseResult.Failure("--output requires a path argument"); - } - - outputPath = args[++i]; - break; - - case "--help" - or "-h": - return new ExportParseResult.HelpRequested(); - - default: - if (arg.StartsWith('-')) - { - return new ExportParseResult.Failure($"Unknown option: {arg}"); - } - - break; - } - } - - if (string.IsNullOrEmpty(assemblyPath)) - { - return new ExportParseResult.Failure("--assembly is required"); - } - - if (string.IsNullOrEmpty(typeName)) - { - return new ExportParseResult.Failure("--type is required"); - } - - if (string.IsNullOrEmpty(outputPath)) - { - return new ExportParseResult.Failure("--output is required"); - } - - return new ExportParseResult.Success(assemblyPath, typeName, outputPath); - } } /// @@ -797,24 +745,6 @@ public sealed record Failure(string Message) : MigrateParseResult; public sealed record HelpRequested : MigrateParseResult; } -/// -/// Export subcommand argument parsing result. -/// -public abstract record ExportParseResult -{ - private ExportParseResult() { } - - /// Successfully parsed export arguments. - public sealed record Success(string AssemblyPath, string TypeName, string OutputPath) - : ExportParseResult; - - /// Parse error. - public sealed record Failure(string Message) : ExportParseResult; - - /// Help requested. - public sealed record HelpRequested : ExportParseResult; -} - /// /// Two-phase migrate selector. Implements the NAP requirement that /// SECURITY DEFINER functions referenced by RLS policies be created out diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/MigrationError.cs b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationError.cs index 38fb5ff4..111ea27a 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/MigrationError.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationError.cs @@ -50,14 +50,11 @@ public static MigrationError RlsLqlTranspile(string policyName, string detail) = new($"MIG-E-RLS-LQL-TRANSPILE: policy '{policyName}': {detail}"); /// - /// MIG-E-RLS-MSSQL-UNSUPPORTED — SQL Server RLS attempted before - /// Nimblesite.DataProvider.Migration.SqlServer ships. + /// MIG-E-RLS-MSSQL-UNSUPPORTED — native SQL Server RLS migrations + /// are not implemented by the SQL Server migration package. /// public static MigrationError RlsMssqlUnsupported() => - new( - "MIG-E-RLS-MSSQL-UNSUPPORTED: SQL Server RLS is not yet implemented. " - + "Nimblesite.DataProvider.Migration.SqlServer package does not exist." - ); + new("MIG-E-RLS-MSSQL-UNSUPPORTED: Native SQL Server RLS migrations are not yet supported."); /// /// MIG-E-RLS-RAW-SQL-UNSUPPORTED-ON-PLATFORM — raw-SQL escape hatch diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs index f6a3a8cf..d7c4c424 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs @@ -13,13 +13,15 @@ public static class MigrationRunner /// Platform-specific DDL generator /// Migration options /// Optional logger + /// Optional integrity check run inside the migration transaction /// Result indicating success or failure public static MigrationApplyResult Apply( IDbConnection connection, IReadOnlyList operations, Func generateDdl, MigrationOptions options, - ILogger? logger = null + ILogger? logger = null, + Func? verifyBeforeCommit = null ) { if (operations.Count == 0) @@ -28,6 +30,23 @@ public static MigrationApplyResult Apply( return new MigrationApplyResult.Ok(true); } + if ( + !options.UseTransaction + && !options.DryRun + && operations.Any(op => op is ReplaceRlsPolicyOperation) + ) + { + return new MigrationApplyResult.Error( + MigrationError.FromMessage("Replacing an RLS policy requires a transaction") + ); + } + if (!options.UseTransaction && !options.DryRun && verifyBeforeCommit is not null) + { + return new MigrationApplyResult.Error( + MigrationError.FromMessage("Pre-commit verification requires a transaction") + ); + } + // Check for destructive operations if (!options.AllowDestructive) { @@ -109,6 +128,14 @@ public static MigrationApplyResult Apply( ); } + if (!options.DryRun && verifyBeforeCommit is not null && !verifyBeforeCommit()) + { + transaction?.Rollback(); + return new MigrationApplyResult.Error( + MigrationError.FromMessage("Schema integrity verification failed before commit") + ); + } + transaction?.Commit(); logger?.LogInformation( "Migration completed: {Count} operations applied", @@ -140,6 +167,8 @@ or DropForeignKeyOperation or DropFunctionOperation or RevokePrivilegesOperation or DropRlsPolicyOperation + or DropTriggerOperation or DisableRlsOperation - or DisableForceRlsOperation; + or DisableForceRlsOperation + or RebuildTableOperation { Destructive: true }; } diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj b/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj index e7bae225..ec6b5b32 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj +++ b/Migration/Nimblesite.DataProvider.Migration.Core/Nimblesite.DataProvider.Migration.Core.csproj @@ -9,7 +9,13 @@ + + + diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/PrimaryKeyNullability.cs b/Migration/Nimblesite.DataProvider.Migration.Core/PrimaryKeyNullability.cs new file mode 100644 index 00000000..0b2c35f2 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/PrimaryKeyNullability.cs @@ -0,0 +1,22 @@ +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Primary key columns are NOT NULL on PostgreSQL and SQL Server whatever the schema +/// declares, while SQLite allows NULL in non-integer keys. A key column left at the +/// default nullability therefore takes the platform's behaviour: it is never relaxed +/// and never reported as drift. Implements [MIG-VERIFY-BEFORE-UP-TO-DATE]. +/// +public static class PrimaryKeyNullability +{ + /// + /// True when is a primary key column left at the default nullability. + /// + public static bool UsesPlatformDefault(TableDefinition table, ColumnDefinition column) => + column.IsNullable + && ( + table.PrimaryKey?.Columns.Any(key => + string.Equals(key, column.Name, StringComparison.OrdinalIgnoreCase) + ) + ?? false + ); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/RlsPolicyPredicates.cs b/Migration/Nimblesite.DataProvider.Migration.Core/RlsPolicyPredicates.cs new file mode 100644 index 00000000..ed2b0e14 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/RlsPolicyPredicates.cs @@ -0,0 +1,285 @@ +using SqlParser; +using SqlParser.Ast; +using SqlParser.Dialects; +using PredicateOk = Outcome.Result< + string, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok; + +namespace Nimblesite.DataProvider.Migration.Core; + +// Implements [RLS-DIFF]: compare parsed policy predicates so harmless +// catalog formatting does not hide changes or cause endless reapplication. +// Postgres compares pg_policies text; SQLite compares predicates extracted +// from the RLS triggers/secure view by SqliteRlsSchemaInspector (issue #98). +internal static class RlsPolicyPredicates +{ + internal static bool SameSql(string? left, string? right, RlsPlatform platform) => + Same( + currentSql: left, + currentLql: null, + desiredSql: right, + desiredLql: null, + policyName: string.Empty, + platform: platform + ); + + internal static bool SameUsing( + RlsPolicyDefinition current, + RlsPolicyDefinition desired, + RlsPlatform platform = RlsPlatform.Postgres + ) => + Same( + current.UsingSql, + current.UsingLql, + desired.UsingSql, + desired.UsingLql, + desired.Name, + platform + ); + + internal static bool SameWithCheck( + RlsPolicyDefinition current, + RlsPolicyDefinition desired, + RlsPlatform platform = RlsPlatform.Postgres + ) => + Same( + current.WithCheckSql, + current.WithCheckLql, + desired.WithCheckSql, + desired.WithCheckLql, + desired.Name, + platform + ); + + internal static bool SameScope(RlsPolicyDefinition current, RlsPolicyDefinition desired) => + current.IsPermissive == desired.IsPermissive + && EffectiveOperation(current.Operations) == EffectiveOperation(desired.Operations) + && EffectiveRoles(current.Roles).SetEquals(EffectiveRoles(desired.Roles)); + + internal static bool RequiresRecreate( + RlsPolicyDefinition current, + RlsPolicyDefinition desired + ) => + !SameScope(current, desired) + || HasPredicate(current.UsingSql, current.UsingLql) + && !HasPredicate(desired.UsingSql, desired.UsingLql) + || HasPredicate(current.WithCheckSql, current.WithCheckLql) + && !HasPredicate(desired.WithCheckSql, desired.WithCheckLql); + + private static RlsOperation EffectiveOperation(IReadOnlyList operations) => + operations.Count == 0 || operations.Contains(RlsOperation.All) + ? RlsOperation.All + : operations[0]; + + // Quoted PostgreSQL roles are case-sensitive. Empty and explicit PUBLIC + // both denote every role; every other spelling is an exact identity. + private static HashSet EffectiveRoles(IReadOnlyList roles) => + roles.Count == 0 || roles.Any(IsPublicRole) + ? new HashSet(["public"], StringComparer.Ordinal) + : roles.ToHashSet(StringComparer.Ordinal); + + internal static bool IsPublicRole(string role) => role is "public" or "PUBLIC"; + + private static bool HasPredicate(string? sql, string? lql) => + !string.IsNullOrWhiteSpace(sql) || !string.IsNullOrWhiteSpace(lql); + + private static bool Same( + string? currentSql, + string? currentLql, + string? desiredSql, + string? desiredLql, + string policyName, + RlsPlatform platform + ) + { + var current = Resolve(currentSql, currentLql, policyName, platform); + var desired = Resolve(desiredSql, desiredLql, policyName, platform); + if (!current.Success || !desired.Success) + { + return false; + } + var currentExpression = Parse(current.Sql, platform); + var desiredExpression = Parse(desired.Sql, platform); + return currentExpression is not null && desiredExpression is not null + ? Equivalent(currentExpression, desiredExpression) + : string.Equals(current.Sql?.Trim(), desired.Sql?.Trim(), StringComparison.Ordinal); + } + + private static (bool Success, string? Sql) Resolve( + string? sql, + string? lql, + string name, + RlsPlatform platform + ) + { + if (!string.IsNullOrWhiteSpace(sql)) + { + return (true, sql); + } + if (string.IsNullOrWhiteSpace(lql)) + { + return (true, null); + } + var translated = RlsPredicateTranspiler.Translate(lql, platform, name); + return translated is PredicateOk ok ? (true, ok.Value) : (false, null); + } + + private static Expression? Parse(string? sql, RlsPlatform platform) + { + if (string.IsNullOrWhiteSpace(sql)) + { + return null; + } + try + { + var statements = new Parser().ParseSql($"SELECT 1 WHERE {sql}", Dialect(platform)); + if ( + statements.Count == 1 + && statements[0] is Statement.Select select + && select.Query.Body is SetExpression.SelectExpression body + && body.Select.Selection is Expression expression + ) + { + return expression; + } + } + catch (ParserException) + { + // Keep unparseable SQL distinct unless its text is exactly equal. + } + return null; + } + + // SQL Server policies fail closed before the diff runs, so unlisted + // platforms keep the Postgres dialect used before SQLite comparison existed. + private static Dialect Dialect(RlsPlatform platform) => + platform == RlsPlatform.Sqlite ? new SQLiteDialect() : new PostgreSqlDialect(); + + private static bool Equivalent(Expression current, Expression desired) + { + if (current is Expression.Nested currentNested) + { + return Equivalent(currentNested.Expression, desired); + } + if (desired is Expression.Nested desiredNested) + { + return Equivalent(current, desiredNested.Expression); + } + if ( + current is Expression.Identifier currentId + && desired is Expression.Identifier desiredId + ) + { + return string.Equals( + IdentifierKey(currentId.Ident), + IdentifierKey(desiredId.Ident), + StringComparison.Ordinal + ); + } + if (current is Expression.InList currentIn && desired is Expression.AnyOp desiredAny) + { + return EquivalentInAndAny(currentIn, desiredAny); + } + if (current is Expression.AnyOp currentAny && desired is Expression.InList desiredIn) + { + return EquivalentInAndAny(desiredIn, currentAny); + } + if ( + current is Expression.Function currentFunction + && desired is Expression.Function desiredFunction + ) + { + return EquivalentFunction(currentFunction, desiredFunction); + } + if ( + current is Expression.BinaryOp currentBinary + && desired is Expression.BinaryOp desiredBinary + ) + { + return currentBinary.Op == desiredBinary.Op + && Equivalent(currentBinary.Left, desiredBinary.Left) + && ( + Equivalent(currentBinary.Right, desiredBinary.Right) + || ( + currentBinary.Op == BinaryOperator.Eq + && currentBinary.Left is Expression.Function + && SameBooleanLiteral(currentBinary.Right, desiredBinary.Right) + ) + ); + } + return string.Equals(current.ToString(), desired.ToString(), StringComparison.Ordinal); + } + + private static bool EquivalentInAndAny(Expression.InList list, Expression.AnyOp any) + { + if ( + list.Negated + || any.CompareOp != BinaryOperator.Eq + || any.Right is not Expression.Array array + || list.List.Count != array.Arr.Element.Count + || !Equivalent(list.Expression, any.Left) + ) + { + return false; + } + + // PostgreSQL deparses a text IN-list as = ANY(ARRAY[...::text]). + // Only ignore casts on these text literals; casts elsewhere can + // change policy meaning and must remain visible as drift. + return list + .List.Zip(array.Arr.Element) + .All(pair => + pair.Second + is Expression.Cast + { + DataType: DataType.Text, + Expression: Expression.LiteralValue { Value: Value.SingleQuotedString }, + } cast + && Equivalent(pair.First, cast.Expression) + || Equivalent(pair.First, pair.Second) + ); + } + + private static bool EquivalentFunction(Expression.Function current, Expression.Function desired) + { + var currentName = current.Name.ToString(); + var desiredName = desired.Name.ToString(); + if (string.Equals(currentName, desiredName, StringComparison.Ordinal)) + { + return string.Equals(current.ToString(), desired.ToString(), StringComparison.Ordinal); + } + return string.Equals(currentName, $"public.{desiredName}", StringComparison.Ordinal) + && string.Equals( + (current with { Name = desired.Name }).ToString(), + desired.ToString(), + StringComparison.Ordinal + ); + } + + private static bool SameBooleanLiteral(Expression current, Expression desired) + { + var currentValue = BooleanLiteral(current); + var desiredValue = BooleanLiteral(desired); + return currentValue is not null && currentValue == desiredValue; + } + + private static bool? BooleanLiteral(Expression expression) + { + if (expression is Expression.LiteralValue { Value: Value.Boolean value }) + { + return value.Value; + } + if ( + expression is Expression.LiteralValue { Value: Value.SingleQuotedString quoted } + && bool.TryParse(quoted.Value, out var parsed) + ) + { + return parsed; + } + return null; + } + + private static string IdentifierKey(Ident identifier) => + identifier.QuoteStyle is null ? identifier.Value.ToLowerInvariant() : identifier.Value; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/RlsPredicateTranspiler.TriggerGuard.cs b/Migration/Nimblesite.DataProvider.Migration.Core/RlsPredicateTranspiler.TriggerGuard.cs new file mode 100644 index 00000000..dd967135 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/RlsPredicateTranspiler.TriggerGuard.cs @@ -0,0 +1,500 @@ +using System.Text; +using GuardSqlError = Outcome.Result< + string, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Error; +using GuardSqlOk = Outcome.Result.Ok< + string, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; +using GuardSqlResult = Outcome.Result< + string, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; + +namespace Nimblesite.DataProvider.Migration.Core; + +// Implements [MIG-TRIGGER-GUARD-LQL] from +// docs/specs/declarative-triggers-spec.md (GitHub issue 82). + +/// +/// Trigger guard predicate translation. Extends the RLS predicate transpiler +/// with old./new. row references, and/or +/// composition, and [not] exists(pipeline) subqueries embedded in a +/// larger predicate. +/// +public static partial class RlsPredicateTranspiler +{ + private const string ReservedGuardTokenPrefix = "__TRG_"; + private const string OldRowSentinelPrefix = "__TRG_OLD_"; + private const string NewRowSentinelPrefix = "__TRG_NEW_"; + private const string ExistsMarkerPrefix = "__TRG_EXISTS_"; + + /// + /// Translate a trigger guard LQL predicate to platform-specific SQL. + /// Row column references use old./new. prefixes + /// (case-insensitive) and become platform-quoted OLD./NEW. + /// references. exists(pipeline) and not exists(pipeline) + /// subqueries may appear anywhere in the predicate. Predicates containing + /// the reserved __TRG_ token are rejected so user literals can + /// never collide with internal sentinels. + /// + /// LQL guard predicate. + /// Target platform. + /// Trigger name -- used for error messages. + public static GuardSqlResult TranslateGuardPredicate( + string lql, + RlsPlatform platform, + string triggerName + ) + { + if (string.IsNullOrWhiteSpace(lql)) + { + return new GuardSqlError(MigrationError.RlsEmptyPredicate(triggerName)); + } + if (lql.Contains(ReservedGuardTokenPrefix, StringComparison.Ordinal)) + { + return new GuardSqlError( + MigrationError.FromMessage( + $"Trigger '{triggerName}': guard predicate must not contain the reserved token '{ReservedGuardTokenPrefix}'" + ) + ); + } + + var withRowSentinels = SubstituteRowReferences(lql.Trim()); + var (remainder, segments) = ExtractExistsSegments(withRowSentinels); + + var simple = TranslateSimplePredicate(remainder, platform, triggerName); + if (simple is not GuardSqlOk simpleOk) + { + return simple; + } + + var expanded = ExpandExistsSegments(simpleOk.Value, segments, platform, triggerName); + return expanded is GuardSqlOk expandedOk + ? new GuardSqlOk(RestoreRowReferences(expandedOk.Value, platform)) + : expanded; + } + + /// + /// True when the guard predicate references the given row word + /// (old or new) as a row.column prefix outside + /// string literals. Used to validate row references against the declared + /// trigger events. + /// + internal static bool GuardReferencesRow(string lql, string rowWord) + { + var i = 0; + while (i < lql.Length) + { + if (lql[i] == '\'') + { + i = SkipStringLiteral(lql, i); + continue; + } + if (IsIdentifierStart(lql, i)) + { + var start = i; + i = ReadIdentifierEnd(lql, i); + if (IsRowReference(lql, i, lql[start..i], rowWord)) + { + return true; + } + continue; + } + i++; + } + return false; + } + + private static string ExistsMarker(int index) => $"'{ExistsMarkerPrefix}{index}__'"; + + /// + /// Transpiles each extracted exists-segment and splices it back over its + /// marker. Postgres pipeline output gets an identifier-quoting pass so + /// mixed-case columns survive Postgres case folding. + /// + private static GuardSqlResult ExpandExistsSegments( + string sql, + IReadOnlyList segments, + RlsPlatform platform, + string triggerName + ) + { + for (var index = 0; index < segments.Count; index++) + { + var subquery = TranslateExistsSubquery(segments[index].InnerLql, platform, triggerName); + if (subquery is not GuardSqlOk subqueryOk) + { + return subquery; + } + var body = + platform == RlsPlatform.Postgres + ? QuotePipelineIdentifiersForPostgres(subqueryOk.Value) + : subqueryOk.Value; + var replacement = segments[index].Negated ? $"NOT {body}" : body; + sql = sql.Replace(ExistsMarker(index), replacement, StringComparison.Ordinal); + } + return new GuardSqlOk(sql); + } + + /// + /// Replaces old.col/new.col references with sentinel string + /// literals that survive LQL transpilation. Skips string literals. + /// + private static string SubstituteRowReferences(string source) + { + var sb = new StringBuilder(source.Length + 32); + var i = 0; + while (i < source.Length) + { + if (source[i] == '\'') + { + i = AppendStringLiteral(source, i, sb); + continue; + } + if (IsIdentifierStart(source, i)) + { + var start = i; + i = ReadIdentifierEnd(source, i); + i = AppendWordOrRowSentinel(source, i, source[start..i], sb); + continue; + } + sb.Append(source[i]); + i++; + } + return sb.ToString(); + } + + private static int AppendWordOrRowSentinel(string source, int i, string word, StringBuilder sb) + { + var isOld = IsRowReference(source, i, word, "old"); + if (!isOld && !IsRowReference(source, i, word, "new")) + { + sb.Append(word); + return i; + } + var columnStart = i + 1; + var columnEnd = ReadIdentifierEnd(source, columnStart); + sb.Append('\'') + .Append(isOld ? OldRowSentinelPrefix : NewRowSentinelPrefix) + .Append(source[columnStart..columnEnd]) + .Append("__'"); + return columnEnd; + } + + private static bool IsRowReference(string source, int wordEnd, string word, string rowWord) => + word.Equals(rowWord, StringComparison.OrdinalIgnoreCase) + && wordEnd + 1 < source.Length + && source[wordEnd] == '.' + && (char.IsLetter(source[wordEnd + 1]) || source[wordEnd + 1] == '_'); + + /// + /// Extracts every top-level [not] exists(...) subquery, replacing + /// each with a marker literal so the remainder can be translated as a + /// simple predicate. + /// + private static ( + string Remainder, + IReadOnlyList Segments + ) ExtractExistsSegments(string predicate) + { + var segments = new List(); + var sb = new StringBuilder(predicate.Length); + var i = 0; + while (i < predicate.Length) + { + if (predicate[i] == '\'') + { + i = AppendStringLiteral(predicate, i, sb); + continue; + } + if (IsIdentifierStart(predicate, i)) + { + var start = i; + i = ReadIdentifierEnd(predicate, i); + i = AppendWordOrExistsMarker(predicate, i, predicate[start..i], segments, sb); + continue; + } + sb.Append(predicate[i]); + i++; + } + return (sb.ToString(), segments); + } + + private static int AppendWordOrExistsMarker( + string predicate, + int i, + string word, + List segments, + StringBuilder sb + ) + { + if (word.Equals("not", StringComparison.OrdinalIgnoreCase)) + { + var afterNot = SkipWhitespace(predicate, i); + var keywordEnd = ReadIdentifierEnd(predicate, afterNot); + if ( + predicate[afterNot..keywordEnd].Equals("exists", StringComparison.OrdinalIgnoreCase) + && TryReadSubquery(predicate, keywordEnd, out var negatedInner, out var end) + ) + { + segments.Add(new GuardExistsSegment(negatedInner, Negated: true)); + sb.Append(ExistsMarker(segments.Count - 1)); + return end; + } + } + if ( + word.Equals("exists", StringComparison.OrdinalIgnoreCase) + && TryReadSubquery(predicate, i, out var inner, out var afterSubquery) + ) + { + segments.Add(new GuardExistsSegment(inner, Negated: false)); + sb.Append(ExistsMarker(segments.Count - 1)); + return afterSubquery; + } + sb.Append(word); + return i; + } + + /// + /// Reads a balanced-paren subquery starting at the first ( after + /// . Returns false when no subquery follows. + /// + private static bool TryReadSubquery(string source, int from, out string inner, out int end) + { + inner = string.Empty; + end = from; + var i = SkipWhitespace(source, from); + if (i >= source.Length || source[i] != '(') + { + return false; + } + var openIndex = i; + var depth = 1; + i++; + while (i < source.Length && depth > 0) + { + switch (source[i]) + { + case '\'': + i = SkipStringLiteral(source, i); + continue; + case '(': + depth++; + break; + case ')': + depth--; + if (depth == 0) + { + inner = source[(openIndex + 1)..i]; + end = i + 1; + return true; + } + break; + } + i++; + } + return false; + } + + /// + /// Quotes bare identifiers (including alias-qualified members) in a + /// transpiled Postgres pipeline subquery so mixed-case column names are + /// not case-folded by Postgres. Skips string literals, already-quoted + /// identifiers, SQL keywords, and function calls. + /// + private static string QuotePipelineIdentifiersForPostgres(string sql) + { + var sb = new StringBuilder(sql.Length + 16); + var i = 0; + while (i < sql.Length) + { + var c = sql[i]; + if (c == '\'') + { + i = AppendStringLiteral(sql, i, sb); + continue; + } + if (c == '"') + { + i = AppendQuotedIdentifier(sql, i, sb); + continue; + } + if (char.IsLetter(c) || c == '_') + { + var start = i; + i = ReadIdentifierEnd(sql, i); + var word = sql[start..i]; + sb.Append(ShouldQuotePipelineWord(sql, i, word) ? $"\"{word}\"" : word); + continue; + } + sb.Append(c); + i++; + } + return sb.ToString(); + } + + private static bool ShouldQuotePipelineWord(string sql, int wordEnd, string word) + { + if (IsKeyword(word) || IsPipelineSqlKeyword(word) || IsBuiltinExpr(word)) + { + return false; + } + var next = SkipWhitespace(sql, wordEnd); + return next >= sql.Length || sql[next] != '('; + } + + private static bool IsPipelineSqlKeyword(string word) + { + var u = word.ToUpperInvariant(); + return u + is "JOIN" + or "INNER" + or "LEFT" + or "RIGHT" + or "FULL" + or "OUTER" + or "CROSS" + or "GROUP" + or "BY" + or "ORDER" + or "HAVING" + or "LIMIT" + or "OFFSET" + or "DISTINCT" + or "UNION" + or "INTERSECT" + or "EXCEPT" + or "ALL" + or "ASC" + or "DESC"; + } + + private static int AppendQuotedIdentifier(string source, int start, StringBuilder sb) + { + sb.Append(source[start]); + var i = start + 1; + while (i < source.Length) + { + sb.Append(source[i]); + if (source[i] == '"') + { + return i + 1; + } + i++; + } + return i; + } + + /// + /// Replaces row-reference sentinel literals with platform-quoted + /// OLD./NEW. column references. + /// + private static string RestoreRowReferences(string sql, RlsPlatform platform) + { + var (open, close) = platform == RlsPlatform.Postgres ? ('"', '"') : ('[', ']'); + var restored = ReplaceRowSentinels(sql, OldRowSentinelPrefix, "OLD", open, close); + return ReplaceRowSentinels(restored, NewRowSentinelPrefix, "NEW", open, close); + } + + private static string ReplaceRowSentinels( + string sql, + string sentinelPrefix, + string rowAlias, + char open, + char close + ) + { + var marker = $"'{sentinelPrefix}"; + var sb = new StringBuilder(sql.Length); + var i = 0; + while (i < sql.Length) + { + var idx = sql.IndexOf(marker, i, StringComparison.Ordinal); + if (idx < 0) + { + sb.Append(sql, i, sql.Length - i); + break; + } + sb.Append(sql, i, idx - i); + var columnStart = idx + marker.Length; + var terminator = sql.IndexOf("__'", columnStart, StringComparison.Ordinal); + if (terminator < 0) + { + sb.Append(sql, idx, sql.Length - idx); + break; + } + sb.Append(rowAlias) + .Append('.') + .Append(open) + .Append(sql[columnStart..terminator]) + .Append(close); + i = terminator + 3; + } + return sb.ToString(); + } + + private static bool IsIdentifierStart(string source, int i) => + (char.IsLetter(source[i]) || source[i] == '_') + && ( + i == 0 + || ( + !char.IsLetterOrDigit(source[i - 1]) && source[i - 1] != '_' && source[i - 1] != '.' + ) + ); + + private static int ReadIdentifierEnd(string source, int start) + { + var i = start; + while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_')) + { + i++; + } + return i; + } + + private static int SkipWhitespace(string source, int start) + { + var i = start; + while (i < source.Length && char.IsWhiteSpace(source[i])) + { + i++; + } + return i; + } + + private static int SkipStringLiteral(string source, int start) + { + // start points at the opening quote; returns index after the closing + // quote (handles '' escapes). + var i = start + 1; + while (i < source.Length) + { + if (source[i] == '\'') + { + if (i + 1 < source.Length && source[i + 1] == '\'') + { + i += 2; + continue; + } + return i + 1; + } + i++; + } + return i; + } + + private static int AppendStringLiteral(string source, int start, StringBuilder sb) + { + var end = SkipStringLiteral(source, start); + sb.Append(source, start, end - start); + return end; + } +} + +/// +/// One extracted [not] exists(...) subquery segment of a guard +/// predicate. +/// +internal sealed record GuardExistsSegment(string InnerLql, bool Negated); diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/RlsPredicateTranspiler.cs b/Migration/Nimblesite.DataProvider.Migration.Core/RlsPredicateTranspiler.cs index c011a5cd..5951a606 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/RlsPredicateTranspiler.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/RlsPredicateTranspiler.cs @@ -32,7 +32,7 @@ public enum RlsPlatform /// SQL Server: SESSION_CONTEXT) and delegates exists(pipeline) /// subquery wrappers to the LQL pipeline transpiler. /// -public static class RlsPredicateTranspiler +public static partial class RlsPredicateTranspiler { /// /// Sentinel placeholder used to mark current_user_id() calls diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs index f26ac976..d1f1475b 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs @@ -181,6 +181,12 @@ public sealed record TableDefinition /// table and each contained policy is applied. Implements [RLS-CORE-POLICY]. /// public RlsPolicySetDefinition? RowLevelSecurity { get; init; } + + /// + /// Declarative trigger guards on this table. Implements + /// [MIG-TRIGGER-MODEL] (GitHub issue 82). + /// + public IReadOnlyList Triggers { get; init; } = []; } /// diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.ForeignKeys.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.ForeignKeys.cs new file mode 100644 index 00000000..83e4fc63 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.ForeignKeys.cs @@ -0,0 +1,41 @@ +namespace Nimblesite.DataProvider.Migration.Core; + +public static partial class SchemaDiff +{ + // Implements [MIG-EXISTING-DATABASE-UPGRADE]: unnamed YAML foreign keys + // are identified by their relationship, including on destructive reruns. + private static bool ForeignKeysMatch( + ForeignKeyDefinition current, + ForeignKeyDefinition desired + ) => + ( + string.IsNullOrWhiteSpace(desired.Name) + || string.IsNullOrWhiteSpace(current.Name) + || string.Equals(current.Name, desired.Name, StringComparison.OrdinalIgnoreCase) + ) + && string.Equals( + current.ReferencedSchema, + desired.ReferencedSchema, + StringComparison.OrdinalIgnoreCase + ) + && ForeignKeyRelationshipsMatch(current: current, desired: desired); + + // SQLite catalog names and schema aliases cannot identify constraints. + // Reuse the relationship comparison when planning a SQLite table rebuild. + internal static bool ForeignKeyRelationshipsMatch( + ForeignKeyDefinition current, + ForeignKeyDefinition desired + ) => + current.Columns.SequenceEqual(desired.Columns, StringComparer.OrdinalIgnoreCase) + && string.Equals( + current.ReferencedTable, + desired.ReferencedTable, + StringComparison.OrdinalIgnoreCase + ) + && current.ReferencedColumns.SequenceEqual( + desired.ReferencedColumns, + StringComparer.OrdinalIgnoreCase + ) + && current.OnDelete == desired.OnDelete + && current.OnUpdate == desired.OnUpdate; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.Triggers.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.Triggers.cs new file mode 100644 index 00000000..9cde8b8a --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.Triggers.cs @@ -0,0 +1,57 @@ +namespace Nimblesite.DataProvider.Migration.Core; + +// Implements [MIG-TRIGGER-DIFF] from docs/specs/declarative-triggers-spec.md +// (GitHub issue 82). Mirrors the RLS policy diff: triggers are matched by +// name per table; drops are destructive and gated behind allowDestructive. + +public static partial class SchemaDiff +{ + private static IEnumerable CalculateTriggerDiff( + TableDefinition? current, + TableDefinition desired, + bool allowDestructive, + ILogger? logger + ) + { + var currentNames = + current?.Triggers.Select(t => t.Name).ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var trigger in desired.Triggers) + { + if (!currentNames.Contains(trigger.Name)) + { + logger?.LogDebug( + "Creating trigger {Trigger} on {Schema}.{Table}", + trigger.Name, + desired.Schema, + desired.Name + ); + yield return new CreateTriggerOperation(desired.Schema, desired.Name, trigger); + } + } + + if (!allowDestructive || current is null) + { + yield break; + } + + var desiredNames = desired + .Triggers.Select(t => t.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var trigger in current.Triggers) + { + if (!desiredNames.Contains(trigger.Name)) + { + logger?.LogWarning( + "Trigger {Trigger} on {Schema}.{Table} will be DROPPED", + trigger.Name, + desired.Schema, + desired.Name + ); + yield return new DropTriggerOperation(desired.Schema, desired.Name, trigger.Name); + } + } + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs index a3e01fae..19553f92 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs @@ -53,6 +53,8 @@ public static OperationsResult Calculate( { var operations = new List(); var rlsOperations = new List(); + var foreignKeyAdditions = new List(); + var triggerOperations = new List(); operations.AddRange(CalculateRoleDiff(current, desired, logger)); @@ -92,6 +94,9 @@ public static OperationsResult Calculate( rlsOperations.AddRange( CalculateRlsDiff(null, desiredTable, allowDestructive, logger) ); + triggerOperations.AddRange( + CalculateTriggerDiff(null, desiredTable, allowDestructive, logger) + ); } else { @@ -102,25 +107,26 @@ public static OperationsResult Calculate( allowDestructive, logger ); - operations.AddRange(columnOps); - - // Check for index additions - var indexOps = CalculateIndexDiff( + var fkOps = CalculateForeignKeyDiff( currentTable, desiredTable, allowDestructive, logger ); - operations.AddRange(indexOps); + operations.AddRange(fkOps.OfType()); + operations.AddRange(columnOps); - // Check for foreign key additions - var fkOps = CalculateForeignKeyDiff( + // Check for index additions + var indexOps = CalculateIndexDiff( currentTable, desiredTable, allowDestructive, logger ); - operations.AddRange(fkOps); + operations.AddRange(indexOps); + + // Add foreign keys after all referenced tables and columns exist. + foreignKeyAdditions.AddRange(fkOps.OfType()); var uniqueOps = CalculateUniqueConstraintDiff( currentTable, @@ -136,6 +142,9 @@ public static OperationsResult Calculate( rlsOperations.AddRange( CalculateRlsDiff(currentTable, desiredTable, allowDestructive, logger) ); + triggerOperations.AddRange( + CalculateTriggerDiff(currentTable, desiredTable, allowDestructive, logger) + ); } } @@ -169,9 +178,11 @@ public static OperationsResult Calculate( .ToList(); var grantOps = CalculateGrantDiff(current, desired, allowDestructive, logger).ToList(); + operations.AddRange(foreignKeyAdditions); operations.AddRange(functionOps.Where(op => !IsSupportCleanupOperation(op))); operations.AddRange(grantOps.Where(op => !IsSupportCleanupOperation(op))); operations.AddRange(rlsOperations); + operations.AddRange(triggerOperations); operations.AddRange(functionOps.Where(IsSupportCleanupOperation)); operations.AddRange(grantOps.Where(IsSupportCleanupOperation)); @@ -203,7 +214,7 @@ private static IEnumerable CalculateColumnDiff( // Add new columns foreach (var desiredColumn in desired.Columns) { - if (!currentColumns.ContainsKey(desiredColumn.Name)) + if (!currentColumns.TryGetValue(desiredColumn.Name, out var currentColumn)) { logger?.LogDebug( "Column {Schema}.{Table}.{Column} not found, will add", @@ -213,6 +224,18 @@ private static IEnumerable CalculateColumnDiff( ); yield return new AddColumnOperation(desired.Schema, desired.Name, desiredColumn); } + else if ( + !currentColumn.IsNullable + && desiredColumn.IsNullable + && !PrimaryKeyNullability.UsesPlatformDefault(desired, desiredColumn) + ) + { + yield return new MakeColumnNullableOperation( + desired.Schema, + desired.Name, + desiredColumn.Name + ); + } } // Drop removed columns (only if destructive allowed) @@ -322,15 +345,15 @@ private static IEnumerable CalculateRlsDiff( yield return new EnableForceRlsOperation(desired.Schema, desired.Name); } - var currentPolicyNames = - currentRls?.Policies.Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase) - ?? new HashSet(StringComparer.OrdinalIgnoreCase); + var currentPolicies = + currentRls?.Policies.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); if (desiredEnabled) { foreach (var policy in desiredRls!.Policies) { - if (!currentPolicyNames.Contains(policy.Name)) + if (!currentPolicies.TryGetValue(policy.Name, out var currentPolicy)) { logger?.LogDebug( "Creating RLS policy {Policy} on {Schema}.{Table}", @@ -340,6 +363,37 @@ private static IEnumerable CalculateRlsDiff( ); yield return new CreateRlsPolicyOperation(desired.Schema, desired.Name, policy); } + else + { + // Implements [RLS-DIFF] for SQLite (issue #98): predicates + // are read from the RLS triggers/secure view, so a changed + // predicate drifts exactly like on Postgres. SQLite cannot + // ALTER a trigger or view in place, so drift is emitted as + // a replacement. Unknown live predicates must also be + // reconciled rather than silently preserving security drift. + var platform = + current?.Schema == "main" ? RlsPlatform.Sqlite : RlsPlatform.Postgres; + if (RlsPolicyPredicates.RequiresRecreate(currentPolicy, policy)) + { + yield return new ReplaceRlsPolicyOperation( + desired.Schema, + desired.Name, + policy + ); + } + else if (PredicateDrift(currentPolicy, policy, platform)) + { + logger?.LogDebug( + "Replacing RLS policy {Policy} on {Schema}.{Table} after predicate change", + policy.Name, + desired.Schema, + desired.Name + ); + yield return platform == RlsPlatform.Sqlite + ? new ReplaceRlsPolicyOperation(desired.Schema, desired.Name, policy) + : new AlterRlsPolicyOperation(desired.Schema, desired.Name, policy); + } + } } } @@ -393,6 +447,15 @@ private static IEnumerable CalculateRlsDiff( } } + // Implements [RLS-DIFF]: unknown live predicates cannot establish equality. + private static bool PredicateDrift( + RlsPolicyDefinition currentPolicy, + RlsPolicyDefinition policy, + RlsPlatform platform + ) => + !RlsPolicyPredicates.SameUsing(currentPolicy, policy, platform) + || !RlsPolicyPredicates.SameWithCheck(currentPolicy, policy, platform); + private static IEnumerable CalculateForeignKeyDiff( TableDefinition current, TableDefinition desired, @@ -400,14 +463,10 @@ private static IEnumerable CalculateForeignKeyDiff( ILogger? logger ) { - var currentFks = current - .ForeignKeys.Where(fk => fk.Name is not null) - .ToDictionary(fk => fk.Name!, StringComparer.OrdinalIgnoreCase); - // Add new foreign keys foreach (var desiredFk in desired.ForeignKeys) { - if (desiredFk.Name is not null && !currentFks.ContainsKey(desiredFk.Name)) + if (!current.ForeignKeys.Any(fk => ForeignKeysMatch(fk, desiredFk))) { logger?.LogDebug( "Foreign key {FkName} on {Schema}.{Table} not found, will add", @@ -422,13 +481,12 @@ private static IEnumerable CalculateForeignKeyDiff( // Drop removed foreign keys (only if destructive allowed) if (allowDestructive) { - var desiredFks = desired - .ForeignKeys.Where(fk => fk.Name is not null) - .ToDictionary(fk => fk.Name!, StringComparer.OrdinalIgnoreCase); - foreach (var currentFk in current.ForeignKeys) { - if (currentFk.Name is not null && !desiredFks.ContainsKey(currentFk.Name)) + if ( + currentFk.Name is not null + && !desired.ForeignKeys.Any(fk => ForeignKeysMatch(currentFk, fk)) + ) { logger?.LogDebug("Foreign key {FkName} will be dropped", currentFk.Name); yield return new DropForeignKeyOperation( diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaIntegrityVerifier.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaIntegrityVerifier.cs index 941b105d..4142a9c4 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaIntegrityVerifier.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaIntegrityVerifier.cs @@ -95,7 +95,12 @@ ImmutableArray.Builder mismatches } VerifyColumn( actual: actualColumn, - expected: expectedColumn, + expected: PrimaryKeyNullability.UsesPlatformDefault(expected, expectedColumn) + ? expectedColumn with + { + IsNullable = actualColumn.IsNullable, + } + : expectedColumn, path: $"{TablePath(table: expected)}.{expectedColumn.Name}", isSqlite: IsSqliteSchema(actual.Schema), mismatches: mismatches @@ -221,6 +226,11 @@ ImmutableArray.Builder mismatches $"{tablePath}: foreign key {name} referenced table drifted", mismatches ); + AddIf( + !SameSchema(actual.ReferencedSchema, expected.ReferencedSchema), + $"{tablePath}: foreign key {name} referenced schema drifted", + mismatches + ); AddIf( !SameIdentifiers(actual.ReferencedColumns, expected.ReferencedColumns), $"{tablePath}: foreign key {name} referenced columns drifted", @@ -403,7 +413,27 @@ ImmutableArray.Builder mismatches mismatches.Add( $"{TablePath(table: expected)}: missing row-level security policy {expectedPolicy.Name}" ); + continue; } + if (IsSqliteSchema(actual.Schema)) + { + continue; + } + AddIf( + !RlsPolicyPredicates.SameUsing(actualPolicy, expectedPolicy), + $"{TablePath(table: expected)}: policy {expectedPolicy.Name} USING predicate drifted", + mismatches + ); + AddIf( + !RlsPolicyPredicates.SameWithCheck(actualPolicy, expectedPolicy), + $"{TablePath(table: expected)}: policy {expectedPolicy.Name} WITH CHECK predicate drifted", + mismatches + ); + AddIf( + !RlsPolicyPredicates.SameScope(actualPolicy, expectedPolicy), + $"{TablePath(table: expected)}: policy {expectedPolicy.Name} scope drifted", + mismatches + ); } } @@ -680,7 +710,8 @@ private static bool SameSchema(string actual, string expected) => private static bool IsDefaultSchema(string schema) => string.IsNullOrWhiteSpace(schema) || SameIdentifier(actual: schema, expected: "main") - || SameIdentifier(actual: schema, expected: "public"); + || SameIdentifier(actual: schema, expected: "public") + || SameIdentifier(actual: schema, expected: "dbo"); private static bool IsSqliteSchema(string schema) => SameIdentifier(actual: schema, expected: "main"); @@ -716,7 +747,9 @@ private static string NormalizeSql(string? value) var builder = new StringBuilder(); AppendNormalizedSql(value: value, builder: builder); var normalized = builder.ToString().Trim().TrimEnd(';').Trim(); - return StripTrailingTypeCast(normalized); + return SqlExpressionText.StripOuterParens( + StripTrailingTypeCast(SqlExpressionText.StripOuterParens(normalized)) + ); } // Implements [MIG-VERIFY-DEFAULTS-AND-GRANTS] (#59 Bug 1): diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaJsonContext.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaJsonContext.cs new file mode 100644 index 00000000..208980f7 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaJsonContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Source-generated +/// for and its graph. Implements [MIG-AOT-JSON]: +/// provides reflection-free JSON metadata so is +/// Native AOT and trim compatible. is intentionally +/// not listed — it is handled by registered +/// on the serializer options. +/// +[JsonSerializable(typeof(SchemaDefinition))] +internal sealed partial class SchemaJsonContext : JsonSerializerContext { } diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs index e11f8091..a4010e06 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs @@ -17,12 +17,31 @@ private protected SchemaOperation() { } /// public sealed record CreateTableOperation(TableDefinition Table) : SchemaOperation; +/// +/// Recreate a table in place: create , copy +/// , drop the old table, rename, then re-run +/// (indexes and triggers). Planned by +/// providers that cannot ALTER constraints (SQLite). Implements [MIG-SQLITE-REBUILD]. +/// +public sealed record RebuildTableOperation( + TableDefinition Table, + IReadOnlyList CopyColumns, + IReadOnlyList DependentObjectSql, + bool Destructive +) : SchemaOperation; + /// /// Add a column to an existing table. /// public sealed record AddColumnOperation(string Schema, string TableName, ColumnDefinition Column) : SchemaOperation; +/// +/// Allow NULL values in an existing column. Implements [MIG-EXISTING-DATABASE-UPGRADE]. +/// +public sealed record MakeColumnNullableOperation(string Schema, string TableName, string ColumnName) + : SchemaOperation; + /// /// Create an index on a table. /// @@ -100,6 +119,35 @@ public sealed record CreateRlsPolicyOperation( RlsPolicyDefinition Policy ) : SchemaOperation; +/// +/// Update predicates of an existing row-level security policy. Implements [RLS-DIFF]. +/// +public sealed record AlterRlsPolicyOperation( + string Schema, + string TableName, + RlsPolicyDefinition Policy +) : SchemaOperation; + +/// +/// Atomically replace a policy when a predicate is removed or its scope changes. +/// Implements [RLS-DIFF]. +/// +public sealed record ReplaceRlsPolicyOperation( + string Schema, + string TableName, + RlsPolicyDefinition Policy +) : SchemaOperation; + +/// +/// Create a declarative trigger guard. Additive. Implements +/// [MIG-TRIGGER-DIFF] (GitHub issue 82). +/// +public sealed record CreateTriggerOperation( + string Schema, + string TableName, + TriggerDefinition Trigger +) : SchemaOperation; + // ═══════════════════════════════════════════════════════════════════ // DESTRUCTIVE OPERATIONS - Require explicit opt-in // ═══════════════════════════════════════════════════════════════════ @@ -162,6 +210,14 @@ public sealed record RevokePrivilegesOperation(PostgresGrantDefinition Grant) : public sealed record DropRlsPolicyOperation(string Schema, string TableName, string PolicyName) : SchemaOperation; +/// +/// Drop a declarative trigger guard -- removes an enforcement rule. +/// DESTRUCTIVE - requires explicit opt-in. Implements [MIG-TRIGGER-DIFF] +/// (GitHub issue 82). +/// +public sealed record DropTriggerOperation(string Schema, string TableName, string TriggerName) + : SchemaOperation; + /// /// Disable row-level security on a table. DESTRUCTIVE - requires explicit /// opt-in because rows previously hidden by policies become visible. diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs index 5a1c44bb..5aceb9c4 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaSerializer.cs @@ -9,13 +9,20 @@ namespace Nimblesite.DataProvider.Migration.Core; /// public static class SchemaSerializer { - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new PortableTypeJsonConverter() }, - }; + // Implements [MIG-AOT-JSON]: serialize through the source-generated + // JsonTypeInfo so the call carries no reflection (IL2026/IL3050). The + // PortableType converter is attached to the generated context's options; + // the typed JsonTypeInfo overloads — not the generic Serialize(options) + // overloads — are what keep the path AOT/trim clean. + private static readonly SchemaJsonContext Context = new( + new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new PortableTypeJsonConverter() }, + } + ); /// /// Serialize a schema definition to JSON string. @@ -23,7 +30,7 @@ public static class SchemaSerializer /// Schema to serialize. /// JSON representation of the schema. public static string ToJson(SchemaDefinition schema) => - JsonSerializer.Serialize(schema, JsonOptions); + JsonSerializer.Serialize(schema, Context.SchemaDefinition); /// /// Deserialize a schema definition from JSON string. @@ -31,7 +38,7 @@ public static string ToJson(SchemaDefinition schema) => /// JSON string. /// Deserialized schema definition. public static SchemaDefinition FromJson(string json) => - JsonSerializer.Deserialize(json, JsonOptions) + JsonSerializer.Deserialize(json, Context.SchemaDefinition) ?? throw new JsonException("Failed to deserialize schema"); /// diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlReader.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlReader.cs new file mode 100644 index 00000000..43174c07 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlReader.cs @@ -0,0 +1,341 @@ +using System.Globalization; +using YamlDotNet.RepresentationModel; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Reflection-free, Native-AOT-safe deserializer that maps a YAML string to a +/// using only the YamlDotNet representation model. +/// +internal static class SchemaYamlReader +{ + /// Parse a YAML document into a . + public static SchemaDefinition Read(string yaml) + { + var root = LoadRootMapping(yaml); + if (root is null) + { + return new SchemaDefinition { Name = string.Empty, Tables = [] }; + } + + return new SchemaDefinition + { + Name = ReadString(root, "name") ?? string.Empty, + Tables = ReadList(root, "tables", ReadTable), + Roles = ReadList(root, "roles", ReadRole), + Functions = ReadList(root, "functions", ReadFunction), + Grants = ReadList(root, "grants", ReadGrant), + }; + } + + private static YamlMappingNode? LoadRootMapping(string yaml) + { + if (string.IsNullOrWhiteSpace(yaml)) + { + return null; + } + + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + + if (stream.Documents.Count == 0) + { + return null; + } + + return stream.Documents[0].RootNode as YamlMappingNode; + } + + private static TableDefinition ReadTable(YamlNode node) + { + var map = AsMapping(node); + return new TableDefinition + { + Schema = ReadString(map, "schema") ?? "public", + Name = ReadString(map, "name") ?? string.Empty, + Columns = ReadList(map, "columns", ReadColumn), + Indexes = ReadList(map, "indexes", ReadIndex), + ForeignKeys = ReadList(map, "foreignKeys", ReadForeignKey), + PrimaryKey = ReadOptional(map, "primaryKey", ReadPrimaryKey), + UniqueConstraints = ReadList(map, "uniqueConstraints", ReadUniqueConstraint), + CheckConstraints = ReadList(map, "checkConstraints", ReadCheckConstraint), + Comment = ReadString(map, "comment"), + RowLevelSecurity = ReadOptional(map, "rowLevelSecurity", ReadRlsPolicySet), + Triggers = ReadList(map, "triggers", ReadTrigger), + }; + } + + private static TriggerDefinition ReadTrigger(YamlNode node) + { + var map = AsMapping(node); + return new TriggerDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Timing = + ReadString(map, "timing")?.ToUpperInvariant() == "AFTER" + ? TriggerTiming.After + : TriggerTiming.Before, + Events = ReadList(map, "events", ReadTriggerEvent), + ForEachRow = ReadBool(map, "forEachRow") ?? true, + RaiseWhenLql = ReadString(map, "raiseWhen"), + ErrorMessage = ReadString(map, "errorMessage"), + }; + } + + private static TriggerEvent ReadTriggerEvent(YamlNode node) => + (node as YamlScalarNode)?.Value?.ToUpperInvariant() switch + { + "UPDATE" => TriggerEvent.Update, + "DELETE" => TriggerEvent.Delete, + _ => TriggerEvent.Insert, + }; + + private static ColumnDefinition ReadColumn(YamlNode node) + { + var map = AsMapping(node); + return new ColumnDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Type = ReadPortableType(map, "type"), + IsNullable = ReadBool(map, "isNullable") ?? true, + DefaultValue = ReadString(map, "defaultValue"), + DefaultLqlExpression = ReadString(map, "defaultLqlExpression"), + IsIdentity = ReadBool(map, "isIdentity") ?? false, + IdentitySeed = ReadLong(map, "identitySeed") ?? 1L, + IdentityIncrement = ReadLong(map, "identityIncrement") ?? 1L, + ComputedExpression = ReadString(map, "computedExpression"), + IsComputedPersisted = ReadBool(map, "isComputedPersisted") ?? false, + Collation = ReadString(map, "collation"), + CheckConstraint = ReadString(map, "checkConstraint"), + CheckConstraintName = ReadString(map, "checkConstraintName"), + Comment = ReadString(map, "comment"), + }; + } + + private static IndexDefinition ReadIndex(YamlNode node) + { + var map = AsMapping(node); + return new IndexDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Columns = ReadStringList(map, "columns"), + Expressions = ReadStringList(map, "expressions"), + IsUnique = ReadBool(map, "isUnique") ?? false, + Filter = ReadString(map, "filter"), + }; + } + + private static ForeignKeyDefinition ReadForeignKey(YamlNode node) + { + var map = AsMapping(node); + return new ForeignKeyDefinition + { + Name = ReadString(map, "name"), + Columns = ReadStringList(map, "columns"), + ReferencedTable = ReadString(map, "referencedTable") ?? string.Empty, + ReferencedSchema = ReadString(map, "referencedSchema") ?? "public", + ReferencedColumns = ReadStringList(map, "referencedColumns"), + OnDelete = ReadForeignKeyAction(map, "onDelete"), + OnUpdate = ReadForeignKeyAction(map, "onUpdate"), + }; + } + + private static PrimaryKeyDefinition ReadPrimaryKey(YamlNode node) + { + var map = AsMapping(node); + return new PrimaryKeyDefinition + { + Name = ReadString(map, "name"), + Columns = ReadStringList(map, "columns"), + }; + } + + private static UniqueConstraintDefinition ReadUniqueConstraint(YamlNode node) + { + var map = AsMapping(node); + return new UniqueConstraintDefinition + { + Name = ReadString(map, "name"), + Columns = ReadStringList(map, "columns"), + }; + } + + private static CheckConstraintDefinition ReadCheckConstraint(YamlNode node) + { + var map = AsMapping(node); + return new CheckConstraintDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Expression = ReadString(map, "expression") ?? string.Empty, + }; + } + + private static RlsPolicySetDefinition ReadRlsPolicySet(YamlNode node) + { + var map = AsMapping(node); + return new RlsPolicySetDefinition + { + Enabled = ReadBool(map, "enabled") ?? true, + Policies = ReadList(map, "policies", ReadRlsPolicy), + Forced = ReadBool(map, "forced") ?? false, + }; + } + + private static RlsPolicyDefinition ReadRlsPolicy(YamlNode node) + { + var map = AsMapping(node); + var operationsNode = FindValue(map, "operations"); + return new RlsPolicyDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + IsPermissive = ReadBool(map, "permissive") ?? true, + Operations = operationsNode is null + ? [RlsOperation.All] + : ReadSequenceItems(operationsNode, ReadRlsOperationNode), + Roles = ReadStringList(map, "roles"), + UsingLql = ReadString(map, "using"), + WithCheckLql = ReadString(map, "withCheck"), + UsingSql = ReadString(map, "usingSql"), + WithCheckSql = ReadString(map, "withCheckSql"), + }; + } + + private static PostgresRoleDefinition ReadRole(YamlNode node) + { + var map = AsMapping(node); + return new PostgresRoleDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Login = ReadBool(map, "login") ?? false, + BypassRls = ReadBool(map, "bypassRls") ?? false, + GrantTo = ReadStringList(map, "grantTo"), + }; + } + + private static PostgresFunctionDefinition ReadFunction(YamlNode node) + { + var map = AsMapping(node); + return new PostgresFunctionDefinition + { + Schema = ReadString(map, "schema") ?? "public", + Name = ReadString(map, "name") ?? string.Empty, + Arguments = ReadList(map, "arguments", ReadFunctionArgument), + Returns = ReadString(map, "returns") ?? "void", + Language = ReadString(map, "language") ?? "sql", + Volatility = ReadString(map, "volatility") ?? "stable", + SecurityDefiner = ReadBool(map, "securityDefiner") ?? false, + Body = ReadString(map, "body") ?? string.Empty, + BodyLql = ReadString(map, "bodyLql"), + ExecuteRoles = ReadStringList(map, "executeRoles"), + RevokePublicExecute = ReadBool(map, "revokePublicExecute") ?? true, + }; + } + + private static PostgresFunctionArgumentDefinition ReadFunctionArgument(YamlNode node) + { + var map = AsMapping(node); + return new PostgresFunctionArgumentDefinition + { + Name = ReadString(map, "name") ?? string.Empty, + Type = ReadString(map, "type") ?? string.Empty, + }; + } + + private static PostgresGrantDefinition ReadGrant(YamlNode node) + { + var map = AsMapping(node); + return new PostgresGrantDefinition + { + Schema = ReadString(map, "schema") ?? "public", + Target = ReadGrantTarget(map, "target"), + ObjectName = ReadString(map, "objectName"), + Privileges = ReadStringList(map, "privileges"), + Roles = ReadStringList(map, "roles"), + RunAs = ReadString(map, "runAs"), + }; + } + + private static PortableType ReadPortableType(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode s + ? SchemaYamlScalars.ParseType(s.Value ?? string.Empty) + : new TextType(); + + private static ForeignKeyAction ReadForeignKeyAction(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + ? SchemaYamlScalars.ParseForeignKeyAction(v) + : ForeignKeyAction.NoAction; + + private static RlsOperation ReadRlsOperationNode(YamlNode node) => + node is YamlScalarNode { Value: { } v } + ? SchemaYamlScalars.ParseRlsOperation(v) + : RlsOperation.All; + + private static PostgresGrantTarget ReadGrantTarget(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + ? SchemaYamlScalars.ParseGrantTarget(v) + : PostgresGrantTarget.Table; + + private static YamlMappingNode AsMapping(YamlNode node) => + node as YamlMappingNode ?? new YamlMappingNode(); + + private static YamlNode? FindValue(YamlMappingNode map, string key) => + map.Children.TryGetValue(new YamlScalarNode(key), out var value) ? value : null; + + private static string? ReadString(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } ? v : null; + + private static bool? ReadBool(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + ? v.Trim().ToUpperInvariant() switch + { + "TRUE" or "YES" or "ON" or "1" => true, + "FALSE" or "NO" or "OFF" or "0" => false, + _ => bool.TryParse(v, out var b) ? b : (bool?)null, + } + : null; + + private static long? ReadLong(YamlMappingNode map, string key) => + FindValue(map, key) is YamlScalarNode { Value: { } v } + && long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) + ? n + : (long?)null; + + private static System.Collections.Immutable.ImmutableArray ReadList( + YamlMappingNode map, + string key, + Func readItem + ) => FindValue(map, key) is YamlSequenceNode seq ? ReadSequenceItems(seq, readItem) : []; + + private static System.Collections.Immutable.ImmutableArray ReadSequenceItems( + YamlNode node, + Func readItem + ) + { + if (node is not YamlSequenceNode seq) + { + return []; + } + + var builder = System.Collections.Immutable.ImmutableArray.CreateBuilder( + seq.Children.Count + ); + foreach (var child in seq.Children) + { + builder.Add(readItem(child)); + } + + return builder.ToImmutable(); + } + + private static System.Collections.Immutable.ImmutableArray ReadStringList( + YamlMappingNode map, + string key + ) => + FindValue(map, key) is YamlSequenceNode seq + ? ReadSequenceItems(seq, n => n is YamlScalarNode { Value: { } v } ? v : string.Empty) + : []; + + private static T? ReadOptional(YamlMappingNode map, string key, Func read) + where T : class => FindValue(map, key) is { } node ? read(node) : null; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlScalars.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlScalars.cs new file mode 100644 index 00000000..433411e3 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlScalars.cs @@ -0,0 +1,214 @@ +using System.Globalization; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Reflection-free scalar encode/parse helpers shared by the hand-written +/// YAML writer and reader. Implements [MIG-AOT-YAML]. The encodings here are +/// the single source of truth for how , foreign-key +/// actions, RLS operations and grant targets appear in schema YAML; they match +/// the format the previous YamlDotNet converters produced so existing schema +/// files and tests keep round-tripping. +/// +internal static class SchemaYamlScalars +{ + /// Encodes a as its scalar string form. + internal static string Encode(PortableType value) => + value switch + { + TinyIntType => "TinyInt", + SmallIntType => "SmallInt", + IntType => "Int", + BigIntType => "BigInt", + DecimalType d => $"Decimal({d.Precision},{d.Scale})", + FloatType => "Float", + DoubleType => "Double", + MoneyType => "Money", + SmallMoneyType => "SmallMoney", + BooleanType => "Boolean", + CharType c => $"Char({c.Length})", + VarCharType v => $"VarChar({v.MaxLength})", + NCharType nc => $"NChar({nc.Length})", + NVarCharType nv when nv.MaxLength == int.MaxValue => "NVarChar(max)", + NVarCharType nv => $"NVarChar({nv.MaxLength})", + TextType => "Text", + BinaryType b => $"Binary({b.Length})", + VarBinaryType vb when vb.MaxLength == int.MaxValue => "VarBinary(max)", + VarBinaryType vb => $"VarBinary({vb.MaxLength})", + BlobType => "Blob", + DateType => "Date", + TimeType t when t.Precision == 7 => "Time", + TimeType t => $"Time({t.Precision})", + DateTimeType dt when dt.Precision == 3 => "DateTime", + DateTimeType dt => $"DateTime({dt.Precision})", + DateTimeOffsetType => "DateTimeOffset", + UuidType => "Uuid", + JsonType => "Json", + XmlType => "Xml", + RowVersionType => "RowVersion", + GeometryType g when g.Srid.HasValue => $"Geometry({g.Srid})", + GeometryType => "Geometry", + GeographyType g when g.Srid == 4326 => "Geography", + GeographyType g => $"Geography({g.Srid})", + EnumType e => $"Enum({e.Name}:{string.Join("|", e.Values)})", + VectorType v => $"Vector({v.Dimensions})", + _ => "Text", + }; + + /// Parses a scalar string into a . + internal static PortableType ParseType(string typeStr) + { + var trimmed = typeStr.Trim(); + + var parenIndex = trimmed.IndexOf('(', StringComparison.Ordinal); + if (parenIndex > 0) + { + var typeName = trimmed[..parenIndex]; + var paramsStr = trimmed[(parenIndex + 1)..^1]; + return ParseParameterized(typeName.ToUpperInvariant(), paramsStr); + } + + return trimmed.ToUpperInvariant() switch + { + "TINYINT" => new TinyIntType(), + "SMALLINT" => new SmallIntType(), + "INT" or "INTEGER" => new IntType(), + "BIGINT" => new BigIntType(), + "FLOAT" or "REAL" => new FloatType(), + "DOUBLE" => new DoubleType(), + "MONEY" => new MoneyType(), + "SMALLMONEY" => new SmallMoneyType(), + "BOOLEAN" or "BOOL" => new BooleanType(), + "TEXT" => new TextType(), + "BLOB" => new BlobType(), + "DATE" => new DateType(), + "TIME" => new TimeType(), + "DATETIME" => new DateTimeType(), + "DATETIMEOFFSET" => new DateTimeOffsetType(), + "UUID" or "GUID" => new UuidType(), + "JSON" or "JSONB" => new JsonType(), + "XML" => new XmlType(), + "ROWVERSION" or "TIMESTAMP" => new RowVersionType(), + "GEOMETRY" => new GeometryType(null), + "GEOGRAPHY" => new GeographyType(), + _ => new TextType(), + }; + } + + private static PortableType ParseParameterized(string typeName, string paramsStr) => + typeName switch + { + "DECIMAL" => ParseDecimal(paramsStr), + "CHAR" => new CharType(ParseInt(paramsStr)), + "VARCHAR" => new VarCharType(ParseMaxLength(paramsStr)), + "NCHAR" => new NCharType(ParseInt(paramsStr)), + "NVARCHAR" => new NVarCharType(ParseMaxLength(paramsStr)), + "BINARY" => new BinaryType(ParseInt(paramsStr)), + "VARBINARY" => new VarBinaryType(ParseMaxLength(paramsStr)), + "TIME" => new TimeType(ParseInt(paramsStr)), + "DATETIME" => new DateTimeType(ParseInt(paramsStr)), + "GEOMETRY" => new GeometryType(ParseInt(paramsStr)), + "GEOGRAPHY" => new GeographyType(ParseInt(paramsStr)), + "ENUM" => ParseEnum(paramsStr), + "VECTOR" => new VectorType(ParseInt(paramsStr)), + _ => new TextType(), + }; + + private static int ParseInt(string s) => int.Parse(s, CultureInfo.InvariantCulture); + + private static int ParseMaxLength(string s) => + s.Equals("max", StringComparison.OrdinalIgnoreCase) ? int.MaxValue : ParseInt(s); + + private static DecimalType ParseDecimal(string paramsStr) + { + var parts = paramsStr.Split(','); + return parts.Length == 2 + ? new DecimalType( + int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), + int.Parse(parts[1].Trim(), CultureInfo.InvariantCulture) + ) + : new DecimalType(int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), 0); + } + + private static EnumType ParseEnum(string paramsStr) + { + var colonIndex = paramsStr.IndexOf(':', StringComparison.Ordinal); + if (colonIndex > 0) + { + var name = paramsStr[..colonIndex]; + var values = paramsStr[(colonIndex + 1)..].Split('|'); + return new EnumType(name, values); + } + + return new EnumType("enum", paramsStr.Split('|')); + } + + /// Encodes a foreign-key action enum value. + internal static string Encode(ForeignKeyAction action) => + action switch + { + ForeignKeyAction.NoAction => "NoAction", + ForeignKeyAction.Cascade => "Cascade", + ForeignKeyAction.SetNull => "SetNull", + ForeignKeyAction.SetDefault => "SetDefault", + ForeignKeyAction.Restrict => "Restrict", + _ => "NoAction", + }; + + /// Parses a foreign-key action scalar, tolerant of spelling variants. + internal static ForeignKeyAction ParseForeignKeyAction(string value) => + value.ToUpperInvariant() switch + { + "NOACTION" or "NO_ACTION" or "NO ACTION" => ForeignKeyAction.NoAction, + "CASCADE" => ForeignKeyAction.Cascade, + "SETNULL" or "SET_NULL" or "SET NULL" => ForeignKeyAction.SetNull, + "SETDEFAULT" or "SET_DEFAULT" or "SET DEFAULT" => ForeignKeyAction.SetDefault, + "RESTRICT" => ForeignKeyAction.Restrict, + _ => ForeignKeyAction.NoAction, + }; + + /// Encodes an RLS operation enum value to its scalar string form. + internal static string Encode(RlsOperation operation) => + operation switch + { + RlsOperation.All => "All", + RlsOperation.Select => "Select", + RlsOperation.Insert => "Insert", + RlsOperation.Update => "Update", + RlsOperation.Delete => "Delete", + _ => "All", + }; + + /// Parses an RLS operation scalar, tolerant of case. + internal static RlsOperation ParseRlsOperation(string value) => + value.ToUpperInvariant() switch + { + "ALL" => RlsOperation.All, + "SELECT" => RlsOperation.Select, + "INSERT" => RlsOperation.Insert, + "UPDATE" => RlsOperation.Update, + "DELETE" => RlsOperation.Delete, + _ => RlsOperation.All, + }; + + /// Encodes a grant-target enum value to its scalar string form. + internal static string Encode(PostgresGrantTarget target) => + target switch + { + PostgresGrantTarget.Schema => "Schema", + PostgresGrantTarget.Table => "Table", + PostgresGrantTarget.AllTablesInSchema => "AllTablesInSchema", + _ => "Table", + }; + + /// Parses a grant-target scalar, tolerant of spelling variants. + internal static PostgresGrantTarget ParseGrantTarget(string value) => + value.ToUpperInvariant() switch + { + "SCHEMA" => PostgresGrantTarget.Schema, + "TABLE" => PostgresGrantTarget.Table, + "ALLTABLESINSCHEMA" or "ALL_TABLES_IN_SCHEMA" or "ALL TABLES IN SCHEMA" => + PostgresGrantTarget.AllTablesInSchema, + _ => PostgresGrantTarget.Table, + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs index ebbf0fc0..1127975b 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs @@ -1,68 +1,14 @@ -using System.Globalization; -using YamlDotNet.Core; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; -using YamlDotNet.Serialization.ObjectGraphVisitors; - namespace Nimblesite.DataProvider.Migration.Core; /// /// Serializes and deserializes schema definitions to/from YAML. /// Used for storing schema definitions as portable configuration files. +/// Implements [MIG-AOT-YAML]: read/write are fully hand-written and +/// reflection-free (Native AOT safe) via and +/// . /// public static class SchemaYamlSerializer { - private static readonly ISerializer Serializer = new SerializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .WithTypeConverter(new PortableTypeYamlConverter()) - .WithTypeConverter(new ForeignKeyActionYamlConverter()) - .WithTypeConverter(new RlsOperationYamlConverter()) - .WithTypeConverter(new PostgresGrantTargetYamlConverter()) - .ConfigureDefaultValuesHandling( - DefaultValuesHandling.OmitDefaults - | DefaultValuesHandling.OmitNull - | DefaultValuesHandling.OmitEmptyCollections - ) - .WithEmissionPhaseObjectGraphVisitor(args => new PropertyDefaultValueFilter( - args.InnerVisitor - )) - .DisableAliases() - .Build(); - - private static readonly IDeserializer Deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .WithTypeConverter(new PortableTypeYamlConverter()) - .WithTypeConverter(new ForeignKeyActionYamlConverter()) - .WithTypeConverter(new RlsOperationYamlConverter()) - .WithTypeConverter(new PostgresGrantTargetYamlConverter()) - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping< - IReadOnlyList, - List - >() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .WithTypeMapping, List>() - .Build(); - /// /// Serialize a schema definition to YAML string. /// @@ -71,7 +17,7 @@ public static class SchemaYamlSerializer public static string ToYaml(SchemaDefinition schema) { ValidateSupportFunctionBodies(schema); - return Serializer.Serialize(schema); + return SchemaYamlWriter.Write(schema); } /// @@ -81,9 +27,7 @@ public static string ToYaml(SchemaDefinition schema) /// Deserialized schema definition. public static SchemaDefinition FromYaml(string yaml) { - var schema = - Deserializer.Deserialize(yaml) - ?? new SchemaDefinition { Name = string.Empty, Tables = [] }; + var schema = SchemaYamlReader.Read(yaml); ValidateSupportFunctionBodies(schema); return schema; } @@ -127,315 +71,3 @@ private static void ValidateSupportFunctionBodies(SchemaDefinition schema) } } } - -/// -/// YAML type converter for PortableType discriminated union. -/// Serializes types as simple strings like "Text", "Int", "VarChar(255)". -/// -internal sealed class PortableTypeYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => typeof(PortableType).IsAssignableFrom(type); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return ParseType(scalar.Value); - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var typeStr = value switch - { - TinyIntType => "TinyInt", - SmallIntType => "SmallInt", - IntType => "Int", - BigIntType => "BigInt", - DecimalType d => $"Decimal({d.Precision},{d.Scale})", - FloatType => "Float", - DoubleType => "Double", - MoneyType => "Money", - SmallMoneyType => "SmallMoney", - BooleanType => "Boolean", - CharType c => $"Char({c.Length})", - VarCharType v => $"VarChar({v.MaxLength})", - NCharType nc => $"NChar({nc.Length})", - NVarCharType nv when nv.MaxLength == int.MaxValue => "NVarChar(max)", - NVarCharType nv => $"NVarChar({nv.MaxLength})", - TextType => "Text", - BinaryType b => $"Binary({b.Length})", - VarBinaryType vb when vb.MaxLength == int.MaxValue => "VarBinary(max)", - VarBinaryType vb => $"VarBinary({vb.MaxLength})", - BlobType => "Blob", - DateType => "Date", - TimeType t when t.Precision == 7 => "Time", - TimeType t => $"Time({t.Precision})", - DateTimeType dt when dt.Precision == 3 => "DateTime", - DateTimeType dt => $"DateTime({dt.Precision})", - DateTimeOffsetType => "DateTimeOffset", - UuidType => "Uuid", - JsonType => "Json", - XmlType => "Xml", - RowVersionType => "RowVersion", - GeometryType g when g.Srid.HasValue => $"Geometry({g.Srid})", - GeometryType => "Geometry", - GeographyType g when g.Srid == 4326 => "Geography", - GeographyType g => $"Geography({g.Srid})", - EnumType e => $"Enum({e.Name}:{string.Join("|", e.Values)})", - VectorType v => $"Vector({v.Dimensions})", - _ => "Text", - }; - emitter.Emit(new Scalar(typeStr)); - } - - private static PortableType ParseType(string typeStr) - { - var trimmed = typeStr.Trim(); - - // Handle parameterized types - var parenIndex = trimmed.IndexOf('(', StringComparison.Ordinal); - if (parenIndex > 0) - { - var typeName = trimmed[..parenIndex]; - var paramsStr = trimmed[(parenIndex + 1)..^1]; - - return typeName.ToUpperInvariant() switch - { - "DECIMAL" => ParseDecimal(paramsStr), - "CHAR" => new CharType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "VARCHAR" => new VarCharType(ParseMaxLength(paramsStr)), - "NCHAR" => new NCharType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "NVARCHAR" => new NVarCharType(ParseMaxLength(paramsStr)), - "BINARY" => new BinaryType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "VARBINARY" => new VarBinaryType(ParseMaxLength(paramsStr)), - "TIME" => new TimeType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "DATETIME" => new DateTimeType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "GEOMETRY" => new GeometryType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - "GEOGRAPHY" => new GeographyType( - int.Parse(paramsStr, CultureInfo.InvariantCulture) - ), - "ENUM" => ParseEnum(paramsStr), - "VECTOR" => new VectorType(int.Parse(paramsStr, CultureInfo.InvariantCulture)), - _ => new TextType(), - }; - } - - // Handle simple types - return trimmed.ToUpperInvariant() switch - { - "TINYINT" => new TinyIntType(), - "SMALLINT" => new SmallIntType(), - "INT" or "INTEGER" => new IntType(), - "BIGINT" => new BigIntType(), - "FLOAT" or "REAL" => new FloatType(), - "DOUBLE" => new DoubleType(), - "MONEY" => new MoneyType(), - "SMALLMONEY" => new SmallMoneyType(), - "BOOLEAN" or "BOOL" => new BooleanType(), - "TEXT" => new TextType(), - "BLOB" => new BlobType(), - "DATE" => new DateType(), - "TIME" => new TimeType(), - "DATETIME" => new DateTimeType(), - "DATETIMEOFFSET" => new DateTimeOffsetType(), - "UUID" or "GUID" => new UuidType(), - "JSON" or "JSONB" => new JsonType(), - "XML" => new XmlType(), - "ROWVERSION" or "TIMESTAMP" => new RowVersionType(), - "GEOMETRY" => new GeometryType(null), - "GEOGRAPHY" => new GeographyType(), - _ => new TextType(), - }; - } - - private static int ParseMaxLength(string s) => - s.Equals("max", StringComparison.OrdinalIgnoreCase) - ? int.MaxValue - : int.Parse(s, CultureInfo.InvariantCulture); - - private static DecimalType ParseDecimal(string paramsStr) - { - var parts = paramsStr.Split(','); - return parts.Length == 2 - ? new DecimalType( - int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), - int.Parse(parts[1].Trim(), CultureInfo.InvariantCulture) - ) - : new DecimalType(int.Parse(parts[0].Trim(), CultureInfo.InvariantCulture), 0); - } - - private static EnumType ParseEnum(string paramsStr) - { - var colonIndex = paramsStr.IndexOf(':', StringComparison.Ordinal); - if (colonIndex > 0) - { - var name = paramsStr[..colonIndex]; - var values = paramsStr[(colonIndex + 1)..].Split('|'); - return new EnumType(name, values); - } - - return new EnumType("enum", paramsStr.Split('|')); - } -} - -/// -/// YAML type converter for ForeignKeyAction enum. -/// -internal sealed class ForeignKeyActionYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => type == typeof(ForeignKeyAction); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return scalar.Value.ToUpperInvariant() switch - { - "NOACTION" or "NO_ACTION" or "NO ACTION" => ForeignKeyAction.NoAction, - "CASCADE" => ForeignKeyAction.Cascade, - "SETNULL" or "SET_NULL" or "SET NULL" => ForeignKeyAction.SetNull, - "SETDEFAULT" or "SET_DEFAULT" or "SET DEFAULT" => ForeignKeyAction.SetDefault, - "RESTRICT" => ForeignKeyAction.Restrict, - _ => ForeignKeyAction.NoAction, - }; - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var action = (ForeignKeyAction)(value ?? ForeignKeyAction.NoAction); - var str = action switch - { - ForeignKeyAction.NoAction => "NoAction", - ForeignKeyAction.Cascade => "Cascade", - ForeignKeyAction.SetNull => "SetNull", - ForeignKeyAction.SetDefault => "SetDefault", - ForeignKeyAction.Restrict => "Restrict", - _ => "NoAction", - }; - emitter.Emit(new Scalar(str)); - } -} - -/// -/// YAML type converter for the enum. Maps -/// to/from a single scalar like All, Select, etc. -/// -internal sealed class RlsOperationYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => type == typeof(RlsOperation); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return scalar.Value.ToUpperInvariant() switch - { - "ALL" => RlsOperation.All, - "SELECT" => RlsOperation.Select, - "INSERT" => RlsOperation.Insert, - "UPDATE" => RlsOperation.Update, - "DELETE" => RlsOperation.Delete, - _ => RlsOperation.All, - }; - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var op = (RlsOperation)(value ?? RlsOperation.All); - emitter.Emit(new Scalar(op.ToString())); - } -} - -/// -/// YAML type converter for the enum. -/// Implements [RLS-PG-SUPPORT-DDL]. -/// -internal sealed class PostgresGrantTargetYamlConverter : IYamlTypeConverter -{ - /// - public bool Accepts(Type type) => type == typeof(PostgresGrantTarget); - - /// - public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - var scalar = parser.Consume(); - return scalar.Value.ToUpperInvariant() switch - { - "SCHEMA" => PostgresGrantTarget.Schema, - "TABLE" => PostgresGrantTarget.Table, - "ALLTABLESINSCHEMA" or "ALL_TABLES_IN_SCHEMA" or "ALL TABLES IN SCHEMA" => - PostgresGrantTarget.AllTablesInSchema, - _ => PostgresGrantTarget.Table, - }; - } - - /// - public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) - { - var target = (PostgresGrantTarget)(value ?? PostgresGrantTarget.Table); - emitter.Emit(new Scalar(target.ToString())); - } -} - -/// -/// Filters out properties that have their semantic default values. -/// This handles cases where the property initializer differs from the type default. -/// -internal sealed class PropertyDefaultValueFilter(IObjectGraphVisitor next) - : ChainedObjectGraphVisitor(next) -{ - /// - /// Default values per property name -> (expected value type, default value). - /// Uses camelCase names (after naming convention applied). - /// - private static readonly Dictionary SemanticDefaults = - new() - { - // ColumnDefinition semantic defaults - { "isNullable", (typeof(bool), true) }, - { "identitySeed", (typeof(long), 1L) }, - { "identityIncrement", (typeof(long), 1L) }, - // TableDefinition and ForeignKeyDefinition semantic defaults - { "schema", (typeof(string), "public") }, - { "referencedSchema", (typeof(string), "public") }, - { "onDelete", (typeof(ForeignKeyAction), ForeignKeyAction.NoAction) }, - { "onUpdate", (typeof(ForeignKeyAction), ForeignKeyAction.NoAction) }, - // RlsPolicySetDefinition / RlsPolicyDefinition semantic defaults - { "enabled", (typeof(bool), true) }, - { "isPermissive", (typeof(bool), true) }, - // PostgreSQL support object semantic defaults - { "language", (typeof(string), "sql") }, - { "volatility", (typeof(string), "stable") }, - { "revokePublicExecute", (typeof(bool), true) }, - }; - - /// - public override bool EnterMapping( - IPropertyDescriptor key, - IObjectDescriptor value, - IEmitter context, - ObjectSerializer serializer - ) - { - if (SemanticDefaults.TryGetValue(key.Name, out var defaultInfo)) - { - // Match by property name and ensure value type matches expectation - if ( - value.Value != null - && defaultInfo.ValueType.IsAssignableFrom(value.Value.GetType()) - && Equals(value.Value, defaultInfo.Default) - ) - { - return false; - } - } - - return base.EnterMapping(key, value, context, serializer); - } -} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlWriter.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlWriter.cs new file mode 100644 index 00000000..faaf0c32 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlWriter.cs @@ -0,0 +1,446 @@ +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Reflection-free YAML writer for . Implements +/// [MIG-AOT-YAML]: emits via the low-level YamlDotNet + +/// event API (no reflection, Native AOT safe). Key names, ordering, aliases and +/// the set of omitted semantic-default values match the prior reflection-based +/// serializer so existing schema files and tests round-trip unchanged. +/// +internal static class SchemaYamlWriter +{ + internal static string Write(SchemaDefinition schema) + { + using var sw = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); + var emitter = new Emitter(sw); + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart(null, null, isImplicit: true)); + WriteSchema(emitter, schema); + emitter.Emit(new DocumentEnd(isImplicit: true)); + emitter.Emit(new StreamEnd()); + return sw.ToString(); + } + + private static void WriteSchema(Emitter e, SchemaDefinition schema) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, schema.Name); + + WriteSeq(e, "tables", schema.Tables, WriteTable); + WriteSeq(e, "roles", schema.Roles, WriteRole); + WriteSeq(e, "functions", schema.Functions, WriteFunction); + WriteSeq(e, "grants", schema.Grants, WriteGrant); + e.Emit(new MappingEnd()); + } + + private static void WriteTable(Emitter e, TableDefinition t) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, t.Name); + if (t.Schema != "public") + { + Key(e, "schema"); + Str(e, t.Schema); + } + WriteSeq(e, "columns", t.Columns, WriteColumn); + WriteSeq(e, "indexes", t.Indexes, WriteIndex); + if (t.PrimaryKey is { } pk) + { + Key(e, "primaryKey"); + WritePrimaryKey(e, pk); + } + WriteSeq(e, "foreignKeys", t.ForeignKeys, WriteForeignKey); + WriteSeq(e, "uniqueConstraints", t.UniqueConstraints, WriteUnique); + WriteSeq(e, "checkConstraints", t.CheckConstraints, WriteCheck); + StrOpt(e, "comment", t.Comment); + if (t.RowLevelSecurity is { } rls) + { + Key(e, "rowLevelSecurity"); + WriteRls(e, rls); + } + WriteSeq(e, "triggers", t.Triggers, WriteTrigger); + e.Emit(new MappingEnd()); + } + + private static void WriteTrigger(Emitter e, TriggerDefinition trigger) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, trigger.Name); + if (trigger.Timing != TriggerTiming.Before) + { + Key(e, "timing"); + Str(e, "After"); + } + WriteSeq( + e, + "events", + trigger.Events, + (emitter, item) => + Str( + emitter, + item switch + { + TriggerEvent.Update => "Update", + TriggerEvent.Delete => "Delete", + _ => "Insert", + } + ) + ); + if (!trigger.ForEachRow) + { + Key(e, "forEachRow"); + Bool(e, false); + } + StrOpt(e, "raiseWhen", trigger.RaiseWhenLql); + StrOpt(e, "errorMessage", trigger.ErrorMessage); + e.Emit(new MappingEnd()); + } + + private static void WriteColumn(Emitter e, ColumnDefinition c) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, c.Name); + Key(e, "type"); + Str(e, SchemaYamlScalars.Encode(c.Type)); + if (!c.IsNullable) + { + Key(e, "isNullable"); + Bool(e, false); + } + StrOpt(e, "defaultValue", c.DefaultValue); + StrOpt(e, "defaultLqlExpression", c.DefaultLqlExpression); + if (c.IsIdentity) + { + Key(e, "isIdentity"); + Bool(e, true); + } + if (c.IdentitySeed != 1) + { + Key(e, "identitySeed"); + Long(e, c.IdentitySeed); + } + if (c.IdentityIncrement != 1) + { + Key(e, "identityIncrement"); + Long(e, c.IdentityIncrement); + } + StrOpt(e, "computedExpression", c.ComputedExpression); + if (c.IsComputedPersisted) + { + Key(e, "isComputedPersisted"); + Bool(e, true); + } + StrOpt(e, "collation", c.Collation); + StrOpt(e, "checkConstraint", c.CheckConstraint); + StrOpt(e, "checkConstraintName", c.CheckConstraintName); + StrOpt(e, "comment", c.Comment); + e.Emit(new MappingEnd()); + } + + private static void WriteIndex(Emitter e, IndexDefinition i) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, i.Name); + WriteStrSeq(e, "columns", i.Columns); + WriteStrSeq(e, "expressions", i.Expressions); + if (i.IsUnique) + { + Key(e, "isUnique"); + Bool(e, true); + } + StrOpt(e, "filter", i.Filter); + e.Emit(new MappingEnd()); + } + + private static void WritePrimaryKey(Emitter e, PrimaryKeyDefinition pk) + { + e.Emit(new MappingStart()); + StrOpt(e, "name", pk.Name); + WriteStrSeq(e, "columns", pk.Columns); + e.Emit(new MappingEnd()); + } + + private static void WriteForeignKey(Emitter e, ForeignKeyDefinition fk) + { + e.Emit(new MappingStart()); + StrOpt(e, "name", fk.Name); + WriteStrSeq(e, "columns", fk.Columns); + Key(e, "referencedTable"); + Str(e, fk.ReferencedTable); + if (fk.ReferencedSchema != "public") + { + Key(e, "referencedSchema"); + Str(e, fk.ReferencedSchema); + } + WriteStrSeq(e, "referencedColumns", fk.ReferencedColumns); + if (fk.OnDelete != ForeignKeyAction.NoAction) + { + Key(e, "onDelete"); + Str(e, SchemaYamlScalars.Encode(fk.OnDelete)); + } + if (fk.OnUpdate != ForeignKeyAction.NoAction) + { + Key(e, "onUpdate"); + Str(e, SchemaYamlScalars.Encode(fk.OnUpdate)); + } + e.Emit(new MappingEnd()); + } + + private static void WriteUnique(Emitter e, UniqueConstraintDefinition u) + { + e.Emit(new MappingStart()); + StrOpt(e, "name", u.Name); + WriteStrSeq(e, "columns", u.Columns); + e.Emit(new MappingEnd()); + } + + private static void WriteCheck(Emitter e, CheckConstraintDefinition c) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, c.Name); + Key(e, "expression"); + Str(e, c.Expression); + e.Emit(new MappingEnd()); + } + + private static void WriteRole(Emitter e, PostgresRoleDefinition r) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, r.Name); + if (r.Login) + { + Key(e, "login"); + Bool(e, true); + } + if (r.BypassRls) + { + Key(e, "bypassRls"); + Bool(e, true); + } + WriteStrSeq(e, "grantTo", r.GrantTo); + e.Emit(new MappingEnd()); + } + + private static void WriteFunction(Emitter e, PostgresFunctionDefinition f) + { + e.Emit(new MappingStart()); + if (f.Schema != "public") + { + Key(e, "schema"); + Str(e, f.Schema); + } + Key(e, "name"); + Str(e, f.Name); + WriteSeq(e, "arguments", f.Arguments, WriteFunctionArg); + if (f.Returns != "void") + { + Key(e, "returns"); + Str(e, f.Returns); + } + if (f.Language != "sql") + { + Key(e, "language"); + Str(e, f.Language); + } + if (f.Volatility != "stable") + { + Key(e, "volatility"); + Str(e, f.Volatility); + } + if (f.SecurityDefiner) + { + Key(e, "securityDefiner"); + Bool(e, true); + } + if (!string.IsNullOrEmpty(f.Body)) + { + Key(e, "body"); + Str(e, f.Body); + } + StrOpt(e, "bodyLql", f.BodyLql); + WriteStrSeq(e, "executeRoles", f.ExecuteRoles); + if (!f.RevokePublicExecute) + { + Key(e, "revokePublicExecute"); + Bool(e, false); + } + e.Emit(new MappingEnd()); + } + + private static void WriteFunctionArg(Emitter e, PostgresFunctionArgumentDefinition a) + { + e.Emit(new MappingStart()); + if (!string.IsNullOrEmpty(a.Name)) + { + Key(e, "name"); + Str(e, a.Name); + } + Key(e, "type"); + Str(e, a.Type); + e.Emit(new MappingEnd()); + } + + private static void WriteGrant(Emitter e, PostgresGrantDefinition g) + { + e.Emit(new MappingStart()); + if (g.Schema != "public") + { + Key(e, "schema"); + Str(e, g.Schema); + } + if (g.Target != PostgresGrantTarget.Table) + { + Key(e, "target"); + Str(e, SchemaYamlScalars.Encode(g.Target)); + } + StrOpt(e, "objectName", g.ObjectName); + WriteStrSeq(e, "privileges", g.Privileges); + WriteStrSeq(e, "roles", g.Roles); + StrOpt(e, "runAs", g.RunAs); + e.Emit(new MappingEnd()); + } + + private static void WriteRls(Emitter e, RlsPolicySetDefinition rls) + { + e.Emit(new MappingStart()); + // enabled defaults true and is omitted at that default (only false is + // written); forced defaults false and is written only when true. + if (!rls.Enabled) + { + Key(e, "enabled"); + Bool(e, false); + } + WriteSeq(e, "policies", rls.Policies, WritePolicy); + if (rls.Forced) + { + Key(e, "forced"); + Bool(e, true); + } + e.Emit(new MappingEnd()); + } + + private static void WritePolicy(Emitter e, RlsPolicyDefinition p) + { + e.Emit(new MappingStart()); + Key(e, "name"); + Str(e, p.Name); + // permissive defaults true and is omitted at that default; only a + // restrictive (false) policy writes the key. + if (!p.IsPermissive) + { + Key(e, "permissive"); + Bool(e, false); + } + if (!(p.Operations.Count == 1 && p.Operations[0] == RlsOperation.All)) + { + Key(e, "operations"); + e.Emit(new SequenceStart(null, null, isImplicit: true, SequenceStyle.Block)); + foreach (var op in p.Operations) + { + Str(e, SchemaYamlScalars.Encode(op)); + } + e.Emit(new SequenceEnd()); + } + WriteStrSeq(e, "roles", p.Roles); + StrOpt(e, "using", p.UsingLql); + StrOpt(e, "withCheck", p.WithCheckLql); + StrOpt(e, "usingSql", p.UsingSql); + StrOpt(e, "withCheckSql", p.WithCheckSql); + e.Emit(new MappingEnd()); + } + + // ── Primitive emit helpers ─────────────────────────────────────────── + + private static void Key(Emitter e, string name) => Str(e, name); + + private static void Str(Emitter e, string value) + { + // Force literal block style for multi-line values so newlines round-trip + // verbatim and stay readable (LQL/SQL bodies); let the emitter choose for + // single-line values. + var style = value.Contains('\n', StringComparison.Ordinal) + ? ScalarStyle.Literal + : ScalarStyle.Any; + e.Emit( + new Scalar( + AnchorName.Empty, + TagName.Empty, + value, + style, + isPlainImplicit: true, + isQuotedImplicit: true + ) + ); + } + + private static void Bool(Emitter e, bool value) => Plain(e, value ? "true" : "false"); + + private static void Long(Emitter e, long value) => + Plain(e, value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + private static void Plain(Emitter e, string value) => + e.Emit( + new Scalar( + AnchorName.Empty, + TagName.Empty, + value, + ScalarStyle.Plain, + isPlainImplicit: true, + isQuotedImplicit: false + ) + ); + + private static void StrOpt(Emitter e, string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + Key(e, key); + Str(e, value); + } + } + + private static void WriteSeq( + Emitter e, + string key, + IReadOnlyList items, + Action writeItem + ) + { + if (items.Count == 0) + { + return; + } + Key(e, key); + e.Emit(new SequenceStart(null, null, isImplicit: true, SequenceStyle.Block)); + foreach (var item in items) + { + writeItem(e, item); + } + e.Emit(new SequenceEnd()); + } + + private static void WriteStrSeq(Emitter e, string key, IReadOnlyList items) + { + if (items.Count == 0) + { + return; + } + Key(e, key); + e.Emit(new SequenceStart(null, null, isImplicit: true, SequenceStyle.Block)); + foreach (var item in items) + { + Str(e, item); + } + e.Emit(new SequenceEnd()); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SqlExpressionText.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SqlExpressionText.cs new file mode 100644 index 00000000..8f885b19 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/SqlExpressionText.cs @@ -0,0 +1,42 @@ +namespace Nimblesite.DataProvider.Migration.Core; + +/// +/// Text helpers for comparing SQL expressions read back from database catalogs. +/// Implements [MIG-VERIFY-BEFORE-UP-TO-DATE]. +/// +public static class SqlExpressionText +{ + /// + /// Catalogs store defaults, filters and checks with or without redundant + /// parentheses, e.g. (now()) vs now() or SQL Server's ((0)). + /// Remove every pair that encloses the whole expression. + /// + public static string StripOuterParens(string sql) + { + var text = sql.Trim(); + while (text.Length >= 2 && text[0] == '(' && ClosesAtEnd(text)) + { + text = text[1..^1].Trim(); + } + return text; + } + + private static bool ClosesAtEnd(string text) + { + var depth = 0; + for (var i = 0; i < text.Length; i++) + { + depth += text[i] switch + { + '(' => 1, + ')' => -1, + _ => 0, + }; + if (depth == 0) + { + return i == text.Length - 1; + } + } + return false; + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/TriggerDdlSupport.cs b/Migration/Nimblesite.DataProvider.Migration.Core/TriggerDdlSupport.cs new file mode 100644 index 00000000..9a05338a --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/TriggerDdlSupport.cs @@ -0,0 +1,143 @@ +using GuardTranspileError = Outcome.Result< + string, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Error; +using GuardTranspileOk = Outcome.Result< + string, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok; + +namespace Nimblesite.DataProvider.Migration.Core; + +// Implements [MIG-TRIGGER-SQLITE] and [MIG-TRIGGER-PG] shared DDL support +// from docs/specs/declarative-triggers-spec.md (GitHub issue 82). + +/// +/// Shared helpers for platform trigger DDL builders: guard predicate +/// validation and translation, error message defaulting/escaping, and +/// keyword mapping. All failures throw, matching the established +/// DDL-generator pattern; MigrationRunner converts them to error +/// results. +/// +public static class TriggerDdlSupport +{ + /// + /// Name prefix identifying migration-managed trigger objects on every + /// platform. Inspectors ignore triggers without this prefix so + /// destructive runs never drop triggers created outside the migration + /// tool (e.g. Sync change tracking). + /// + public const string ManagedTriggerPrefix = "usr_"; + + /// + /// Name suffix identifying PostgreSQL trigger guard functions. These are + /// owned by the trigger lifecycle and excluded from support-function + /// read-back so destructive runs do not try to drop them independently. + /// + public const string GuardFunctionSuffix = "_trgfn"; + + /// + /// Validates the trigger declaration and translates its guard predicate + /// for the target platform. Throws + /// on invalid declarations and for + /// statement-level triggers. + /// + public static string BuildGuardPredicate(TriggerDefinition trigger, RlsPlatform platform) + { + RequireForEachRow(trigger); + var lql = RequireRaiseWhen(trigger); + ValidateRowReferences(trigger, lql, RequireEvents(trigger)); + var result = RlsPredicateTranspiler.TranslateGuardPredicate(lql, platform, trigger.Name); + return result switch + { + GuardTranspileOk ok => ok.Value, + GuardTranspileError error => throw new InvalidOperationException(error.Value.Message), + }; + } + + /// Returns the distinct declared events or throws when empty. + public static IReadOnlyList RequireEvents(TriggerDefinition trigger) => + trigger.Events.Count > 0 + ? trigger.Events.Distinct().ToList() + : throw new InvalidOperationException($"Trigger '{trigger.Name}' declares no events"); + + /// + /// Error message (defaulted from the trigger name when absent) with + /// single quotes escaped for embedding in a SQL string literal. + /// + public static string EscapedMessage(TriggerDefinition trigger) + { + var message = string.IsNullOrWhiteSpace(trigger.ErrorMessage) + ? $"TRIGGER-GUARD: {trigger.Name}" + : trigger.ErrorMessage; + return message.Replace("'", "''", StringComparison.Ordinal); + } + + /// SQL verb for a trigger event. + public static string EventVerb(TriggerEvent triggerEvent) => + triggerEvent switch + { + TriggerEvent.Insert => "INSERT", + TriggerEvent.Update => "UPDATE", + TriggerEvent.Delete => "DELETE", + _ => throw new NotSupportedException($"Unknown trigger event: {triggerEvent}"), + }; + + /// SQL timing keyword for a trigger timing. + public static string TimingKeyword(TriggerTiming timing) => + timing == TriggerTiming.After ? "AFTER" : "BEFORE"; + + private static string RequireRaiseWhen(TriggerDefinition trigger) => + trigger.RaiseWhenLql is { } lql && !string.IsNullOrWhiteSpace(lql) + ? lql + : throw new InvalidOperationException( + $"Trigger '{trigger.Name}' has no raiseWhen predicate" + ); + + /// + /// Guards are row-level on every platform. SQLite has no statement-level + /// triggers and Postgres statement-level triggers see NULL OLD/NEW, which + /// would silently disable the guard -- so forEachRow: false fails + /// loudly instead of diverging per platform. + /// + private static void RequireForEachRow(TriggerDefinition trigger) + { + if (!trigger.ForEachRow) + { + throw new NotSupportedException( + $"Trigger '{trigger.Name}': forEachRow: false (statement-level) is not supported; guards are row-level on every platform" + ); + } + } + + /// + /// Rejects row references that do not exist for a declared event + /// (old. on INSERT, new. on DELETE). Without this, SQLite + /// fails at DML time while Postgres silently never fires the guard. + /// + private static void ValidateRowReferences( + TriggerDefinition trigger, + string lql, + IReadOnlyList events + ) + { + if ( + events.Contains(TriggerEvent.Insert) + && RlsPredicateTranspiler.GuardReferencesRow(lql, "old") + ) + { + throw new InvalidOperationException( + $"Trigger '{trigger.Name}' references old. but fires on Insert; OLD row values do not exist for INSERT" + ); + } + if ( + events.Contains(TriggerEvent.Delete) + && RlsPredicateTranspiler.GuardReferencesRow(lql, "new") + ) + { + throw new InvalidOperationException( + $"Trigger '{trigger.Name}' references new. but fires on Delete; NEW row values do not exist for DELETE" + ); + } + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/TriggerDefinition.cs b/Migration/Nimblesite.DataProvider.Migration.Core/TriggerDefinition.cs new file mode 100644 index 00000000..5e1b3941 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Core/TriggerDefinition.cs @@ -0,0 +1,67 @@ +using YamlDotNet.Serialization; + +namespace Nimblesite.DataProvider.Migration.Core; + +// Implements [MIG-TRIGGER-MODEL] from docs/specs/declarative-triggers-spec.md +// (GitHub issue 82). + +/// +/// Declarative trigger guard attached to a table. The trigger raises +/// and aborts the statement when +/// evaluates true for the affected row. Diffed by +/// name and materialised by the platform DDL generator, like RLS policies. +/// +public sealed record TriggerDefinition +{ + /// Trigger name -- unique within the table. + public string Name { get; init; } = string.Empty; + + /// When the trigger fires relative to the event. Default Before. + public TriggerTiming Timing { get; init; } = TriggerTiming.Before; + + /// Events the trigger fires on. At least one is required. + public IReadOnlyList Events { get; init; } = []; + + /// True to fire once per affected row (default). + [YamlMember(DefaultValuesHandling = DefaultValuesHandling.Preserve)] + public bool ForEachRow { get; init; } = true; + + /// + /// LQL guard predicate. Row column references use old./new. + /// prefixes; supports and/or composition and + /// [not] exists(pipeline) subqueries. Implements + /// [MIG-TRIGGER-GUARD-LQL]. + /// + [YamlMember(Alias = "raiseWhen")] + public string? RaiseWhenLql { get; init; } + + /// Error message raised when the guard predicate holds. + public string? ErrorMessage { get; init; } +} + +/// +/// When a trigger fires relative to its event. +/// +public enum TriggerTiming +{ + /// Fires before the row change is applied. + Before, + + /// Fires after the row change is applied. + After, +} + +/// +/// DML event a trigger fires on. +/// +public enum TriggerEvent +{ + /// Fires on INSERT. + Insert, + + /// Fires on UPDATE. + Update, + + /// Fires on DELETE. + Delete, +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresCheckExpressionNormalizer.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresCheckExpressionNormalizer.cs index 9fabb10d..2bce9218 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresCheckExpressionNormalizer.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresCheckExpressionNormalizer.cs @@ -120,7 +120,11 @@ private static bool IsRedundantCast(Expression.Cast cast) private static string? TryRewriteAnyArrayToIn(Expression expression) { - if (expression is not Expression.AnyOp any || any.Right is not Expression.Array arr) + if ( + expression is not Expression.AnyOp any + || any.CompareOp != BinaryOperator.Eq + || any.Right is not Expression.Array arr + ) { return null; } @@ -137,6 +141,23 @@ private static bool IsRedundantCast(Expression.Cast cast) return $"{Render(any.Left)} IN ({string.Join(", ", literals)})"; } + private static string? RenderLiteralValue(Value value) + { + if (value is Value.SingleQuotedString s) + { + return $"'{s.Value.Replace("'", "''", StringComparison.Ordinal)}'"; + } + if (value is Value.Number n) + { + return n.Value; + } + if (value is Value.Boolean b) + { + return b.Value ? "true" : "false"; + } + return null; + } + private static string? ExtractStringLiteral(Expression element) { var unwrapped = element; @@ -148,15 +169,7 @@ private static bool IsRedundantCast(Expression.Cast cast) { return null; } - if (lit.Value is Value.SingleQuotedString s) - { - return $"'{s.Value.Replace("'", "''", StringComparison.Ordinal)}'"; - } - if (lit.Value is Value.Number n) - { - return n.Value; - } - return null; + return RenderLiteralValue(lit.Value); } private static string Render(Expression expression) @@ -171,19 +184,20 @@ private static string Render(Expression expression) } if (expression is Expression.LiteralValue lit) { - if (lit.Value is Value.SingleQuotedString s) - { - return $"'{s.Value.Replace("'", "''", StringComparison.Ordinal)}'"; - } - if (lit.Value is Value.Number n) + var rendered = RenderLiteralValue(lit.Value); + if (rendered is not null) { - return n.Value; + return rendered; } } if (expression is Expression.BinaryOp bin) { return $"{Render(bin.Left)} {RenderOp(bin.Op)} {Render(bin.Right)}"; } + if (expression is Expression.AnyOp && TryRewriteAnyArrayToIn(expression) is { } inList) + { + return inList; + } if (expression is Expression.Nested nested) { return $"({Render(nested.Expression)})"; @@ -192,8 +206,10 @@ private static string Render(Expression expression) { return Render(cast.Expression); } - // Fallback: stringify via type name only; lossy but deterministic. - return expression.GetType().Name; + // Preserve every unhandled expression rather than discarding its + // operands. A lossy fallback makes CHECK constraints look different + // on every rerun and can conceal meaningful predicate changes. + return expression.ToSql(); } private static string RenderOp(BinaryOperator op) diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs index f366a004..644f6da6 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs @@ -101,6 +101,8 @@ public static string Generate(SchemaOperation operation) => { CreateTableOperation op => GenerateCreateTable(op.Table), AddColumnOperation op => GenerateAddColumn(op), + MakeColumnNullableOperation op => + $"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" ALTER COLUMN \"{op.ColumnName}\" DROP NOT NULL", CreateIndexOperation op => GenerateCreateIndex(op), AddForeignKeyOperation op => GenerateAddForeignKey(op), AddCheckConstraintOperation op => GenerateAddCheckConstraint(op), @@ -130,6 +132,10 @@ public static string Generate(SchemaOperation operation) => DropRlsPolicyOperation op => $"DROP POLICY IF EXISTS \"{op.PolicyName}\" ON \"{op.Schema}\".\"{op.TableName}\"", CreateRlsPolicyOperation op => GenerateCreateRlsPolicy(op), + AlterRlsPolicyOperation op => GenerateAlterRlsPolicy(op), + ReplaceRlsPolicyOperation op => GenerateReplaceRlsPolicy(op), + CreateTriggerOperation op => PostgresTriggerDdlBuilder.GenerateCreate(op), + DropTriggerOperation op => PostgresTriggerDdlBuilder.GenerateDrop(op), _ => throw new NotSupportedException( $"Unknown operation type: {operation.GetType().Name}" ), @@ -153,27 +159,46 @@ private static string GenerateCreateRlsPolicy(CreateRlsPolicyOperation op) ); sb.Append(CultureInfo.InvariantCulture, $" TO {RlsRolesToPgClause(op.Policy.Roles)}"); + AppendPolicyPredicates(sb, op.Policy); + return sb.ToString(); + } + + private static string GenerateAlterRlsPolicy(AlterRlsPolicyOperation op) + { + ValidatePolicyPredicates(op.Policy); + var sb = new StringBuilder( + $"ALTER POLICY \"{op.Policy.Name}\" ON \"{op.Schema}\".\"{op.TableName}\"" + ); + AppendPolicyPredicates(sb, op.Policy); + return sb.ToString(); + } + + private static string GenerateReplaceRlsPolicy(ReplaceRlsPolicyOperation op) => + $"DROP POLICY \"{op.Policy.Name}\" ON \"{op.Schema}\".\"{op.TableName}\"; " + + GenerateCreateRlsPolicy(new CreateRlsPolicyOperation(op.Schema, op.TableName, op.Policy)); + + private static void AppendPolicyPredicates(StringBuilder sb, RlsPolicyDefinition policy) + { // Raw-SQL escape hatch (issue #36) takes precedence over LQL. - if (!string.IsNullOrWhiteSpace(op.Policy.UsingSql)) + if (!string.IsNullOrWhiteSpace(policy.UsingSql)) { - sb.Append(CultureInfo.InvariantCulture, $" USING ({op.Policy.UsingSql})"); + sb.Append(CultureInfo.InvariantCulture, $" USING ({policy.UsingSql})"); } - else if (!string.IsNullOrWhiteSpace(op.Policy.UsingLql)) + else if (!string.IsNullOrWhiteSpace(policy.UsingLql)) { - var sql = TranslateOrThrow(op.Policy.UsingLql, op.Policy.Name); + var sql = TranslateOrThrow(policy.UsingLql, policy.Name); sb.Append(CultureInfo.InvariantCulture, $" USING ({sql})"); } - if (!string.IsNullOrWhiteSpace(op.Policy.WithCheckSql)) + if (!string.IsNullOrWhiteSpace(policy.WithCheckSql)) { - sb.Append(CultureInfo.InvariantCulture, $" WITH CHECK ({op.Policy.WithCheckSql})"); + sb.Append(CultureInfo.InvariantCulture, $" WITH CHECK ({policy.WithCheckSql})"); } - else if (!string.IsNullOrWhiteSpace(op.Policy.WithCheckLql)) + else if (!string.IsNullOrWhiteSpace(policy.WithCheckLql)) { - var sql = TranslateOrThrow(op.Policy.WithCheckLql, op.Policy.Name); + var sql = TranslateOrThrow(policy.WithCheckLql, policy.Name); sb.Append(CultureInfo.InvariantCulture, $" WITH CHECK ({sql})"); } - return sb.ToString(); } private static void ValidatePolicyPredicates(RlsPolicyDefinition policy) @@ -239,7 +264,9 @@ private static string RlsOperationsToPgClause(IReadOnlyList ops) } private static string RlsRolesToPgClause(IReadOnlyList roles) => - roles.Count == 0 ? "PUBLIC" : string.Join(", ", roles.Select(r => $"\"{r}\"")); + roles.Count == 0 || roles.Any(r => r is "public" or "PUBLIC") + ? "PUBLIC" + : string.Join(", ", roles.Select(r => $"\"{r}\"")); private static string GenerateCreateTable(TableDefinition table) { diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresPolicyCatalogNormalizer.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresPolicyCatalogNormalizer.cs new file mode 100644 index 00000000..6fec2e6d --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresPolicyCatalogNormalizer.cs @@ -0,0 +1,189 @@ +using SqlParser; +using SqlParser.Ast; +using SqlParser.Dialects; +using PredicateOk = Outcome.Result< + string, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok; + +namespace Nimblesite.DataProvider.Migration.Postgres; + +/// +/// Uses PostgreSQL's own parser to compare desired RLS predicates with catalog predicates. +/// Temporary views are dropped in the same session and never alter application schema. +/// Implements [RLS-DIFF]. +/// +public static class PostgresPolicyCatalogNormalizer +{ + /// Canonicalize policies on tables that already exist. + public static SchemaResult Normalize( + NpgsqlConnection connection, + SchemaDefinition live, + SchemaDefinition desired + ) + { + try + { + var existing = live.Tables.Select(t => (t.Schema, t.Name)).ToHashSet(); + var tables = desired + .Tables.Select(t => + existing.Contains((t.Schema, t.Name)) ? NormalizeTable(connection, t) : t + ) + .ToArray(); + return new SchemaResult.Ok( + desired with + { + Tables = tables, + } + ); + } + catch (Exception ex) + { + return new SchemaResult.Error( + MigrationError.FromException(ex) + ); + } + } + + private static TableDefinition NormalizeTable( + NpgsqlConnection connection, + TableDefinition table + ) + { + if (table.RowLevelSecurity is not { } rls) + { + return table; + } + var policies = rls.Policies.Select(p => NormalizePolicy(connection, table, p)).ToArray(); + return table with { RowLevelSecurity = rls with { Policies = policies } }; + } + + private static RlsPolicyDefinition NormalizePolicy( + NpgsqlConnection connection, + TableDefinition table, + RlsPolicyDefinition policy + ) + { + var usingSql = CanonicalizeClause( + connection, + table, + policy.UsingSql, + policy.UsingLql, + policy.Name + ); + var checkSql = CanonicalizeClause( + connection, + table, + policy.WithCheckSql, + policy.WithCheckLql, + policy.Name + ); + return policy with + { + UsingSql = usingSql ?? policy.UsingSql, + UsingLql = usingSql is null ? policy.UsingLql : null, + WithCheckSql = checkSql ?? policy.WithCheckSql, + WithCheckLql = checkSql is null ? policy.WithCheckLql : null, + }; + } + + private static string? CanonicalizeClause( + NpgsqlConnection connection, + TableDefinition table, + string? sql, + string? lql, + string name + ) + { + var expression = Resolve(sql, lql, name); + if (expression is null) + { + return null; + } + try + { + return ReadCanonicalClause(connection, table, expression); + } + catch (PostgresException ex) when (ex.SqlState is "42703" or "42P01" or "42883" or "42704") + { + // A new column, table, or function can be unavailable before the + // structural phase. The original expression remains fail-closed. + return null; + } + } + + private static string? Resolve(string? sql, string? lql, string name) + { + if (!string.IsNullOrWhiteSpace(sql)) + { + return sql; + } + if (string.IsNullOrWhiteSpace(lql)) + { + return null; + } + var translated = RlsPredicateTranspiler.Translate(lql, RlsPlatform.Postgres, name); + return translated is PredicateOk ok ? ok.Value : null; + } + + private static string? ReadCanonicalClause( + NpgsqlConnection connection, + TableDefinition table, + string expression + ) + { + var view = $"dp_rls_check_{Guid.NewGuid():N}"; + using var create = connection.CreateCommand(); + create.CommandText = + $"CREATE TEMP VIEW {PostgresDdlGenerator.QuoteIdent(view)} AS SELECT 1 AS marker FROM {PostgresDdlGenerator.QuoteIdent(table.Schema)}.{PostgresDdlGenerator.QuoteIdent(table.Name)} WHERE ({expression})"; + create.ExecuteNonQuery(); + try + { + return ReadViewPredicate(connection, view); + } + finally + { + using var drop = connection.CreateCommand(); + drop.CommandText = $"DROP VIEW {PostgresDdlGenerator.QuoteIdent(view)}"; + drop.ExecuteNonQuery(); + } + } + + private static string? ReadViewPredicate(NpgsqlConnection connection, string view) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT definition FROM pg_views + WHERE schemaname = pg_my_temp_schema()::regnamespace::text AND viewname = @view + """; + command.Parameters.AddWithValue("view", view); + if (command.ExecuteScalar() is not string definition) + { + throw new InvalidOperationException( + $"Temporary policy view {view} was not found in pg_views" + ); + } + return ParseSelection(definition) + ?? throw new InvalidOperationException( + $"Could not parse temporary policy view: {definition}" + ); + } + + private static string? ParseSelection(string definition) + { + try + { + var statements = new Parser().ParseSql(definition, new PostgreSqlDialect()); + return + statements.Count == 1 + && statements[0] is Statement.Select select + && select.Query.Body is SetExpression.SelectExpression body + ? body.Select.Selection?.ToSql() + : null; + } + catch (ParserException) + { + return null; + } + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs index 59435977..933d1a9d 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs @@ -276,27 +276,48 @@ ORDER BY i.relname } } - // Get foreign keys + // [MIG-EXISTING-DATABASE-UPGRADE] Keep each relationship intact, + // including composite keys and references to another schema. using var fkCmd = connection.CreateCommand(); fkCmd.CommandText = """ SELECT - tc.constraint_name, - kcu.column_name, - ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column, - rc.delete_rule, - rc.update_rule - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - WHERE tc.table_schema = @schema - AND tc.table_name = @table - AND tc.constraint_type = 'FOREIGN KEY' + constraint_record.conname, + array_agg(source_column.attname::text ORDER BY keys.n), + referenced_table.relname, + referenced_schema.nspname, + array_agg(referenced_column.attname::text ORDER BY keys.n), + CASE constraint_record.confdeltype + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + WHEN 'r' THEN 'RESTRICT' + ELSE 'NO ACTION' + END, + CASE constraint_record.confupdtype + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + WHEN 'r' THEN 'RESTRICT' + ELSE 'NO ACTION' + END + FROM pg_constraint constraint_record + JOIN pg_class source_table ON source_table.oid = constraint_record.conrelid + JOIN pg_namespace source_schema ON source_schema.oid = source_table.relnamespace + JOIN pg_class referenced_table ON referenced_table.oid = constraint_record.confrelid + JOIN pg_namespace referenced_schema ON referenced_schema.oid = referenced_table.relnamespace + JOIN unnest(constraint_record.conkey, constraint_record.confkey) + WITH ORDINALITY AS keys(source_attnum, referenced_attnum, n) ON true + JOIN pg_attribute source_column + ON source_column.attrelid = source_table.oid + AND source_column.attnum = keys.source_attnum + JOIN pg_attribute referenced_column + ON referenced_column.attrelid = referenced_table.oid + AND referenced_column.attnum = keys.referenced_attnum + WHERE source_schema.nspname = @schema + AND source_table.relname = @table + AND constraint_record.contype = 'f' + GROUP BY constraint_record.oid, referenced_table.relname, referenced_schema.nspname + ORDER BY constraint_record.conname """; fkCmd.Parameters.AddWithValue("@schema", schemaName); fkCmd.Parameters.AddWithValue("@table", tableName); @@ -306,20 +327,21 @@ JOIN information_schema.referential_constraints rc while (reader.Read()) { var constraintName = reader.GetString(0); - var columnName = reader.GetString(1); + var columnNames = reader.GetFieldValue(1); var refTable = reader.GetString(2); - var refColumn = reader.GetString(3); - var deleteRule = reader.GetString(4); - var updateRule = reader.GetString(5); + var refSchema = reader.GetString(3); + var refColumns = reader.GetFieldValue(4); + var deleteRule = reader.GetString(5); + var updateRule = reader.GetString(6); foreignKeys.Add( new ForeignKeyDefinition { Name = constraintName, - Columns = [columnName], + Columns = columnNames, ReferencedTable = refTable, - ReferencedSchema = schemaName, - ReferencedColumns = [refColumn], + ReferencedSchema = refSchema, + ReferencedColumns = refColumns, OnDelete = ParseForeignKeyAction(deleteRule), OnUpdate = ParseForeignKeyAction(updateRule), } @@ -344,6 +366,12 @@ JOIN information_schema.referential_constraints rc UniqueConstraints = uniqueConstraints.AsReadOnly(), CheckConstraints = checkConstraints.AsReadOnly(), RowLevelSecurity = rls, + // [MIG-TRIGGER-PG] read declarative trigger guards by name. + Triggers = PostgresTriggerSchemaInspector.Inspect( + connection, + schemaName, + tableName + ), } ); } diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs index 74f9944a..279c9e1c 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs @@ -175,7 +175,7 @@ private static string PrivilegeList(IReadOnlyList privileges) => private static string QuoteIdentList(IReadOnlyList identifiers) => string.Join(", ", identifiers.Select(QuoteIdent)); - private static string QuoteIdent(string identifier) => + internal static string QuoteIdent(string identifier) => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; private static string QuoteLiteral(string value) => diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportSchemaInspector.cs index 1da3dbb3..4060cf10 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportSchemaInspector.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportSchemaInspector.cs @@ -87,9 +87,17 @@ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_language l ON l.oid = p.prolang WHERE n.nspname = @schema + -- [MIG-TRIGGER-PG]: trigger guard functions are owned by the + -- trigger lifecycle; reading them back would make destructive + -- runs try to drop them while their trigger still depends on them. + AND p.proname NOT LIKE @guardFunctionPattern ESCAPE '\' ORDER BY n.nspname, p.proname, p.oid """; command.Parameters.AddWithValue("@schema", schemaName); + command.Parameters.AddWithValue( + "@guardFunctionPattern", + $"%{TriggerDdlSupport.GuardFunctionSuffix.Replace("_", "\\_", StringComparison.Ordinal)}" + ); using var reader = command.ExecuteReader(); var functions = new List(); diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresTriggerDdlBuilder.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresTriggerDdlBuilder.cs new file mode 100644 index 00000000..7f2c2698 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresTriggerDdlBuilder.cs @@ -0,0 +1,98 @@ +namespace Nimblesite.DataProvider.Migration.Postgres; + +// Implements [MIG-TRIGGER-PG] from docs/specs/declarative-triggers-spec.md +// (GitHub issue 82). + +/// +/// Emits PostgreSQL DDL for declarative trigger guards: one plpgsql guard +/// function ({table}_{trigger}_trgfn) that raises the declared error +/// when the guard predicate holds, plus one trigger named +/// usr_{trigger} wired to the declared events. The usr_ prefix +/// marks the trigger as migration-managed so the inspector never reads (and +/// destructive runs never drop) triggers created outside the tool. +/// RAISE ... USING MESSAGE avoids format-string interpretation of +/// % in user messages, and the dollar-quote tag is chosen to never +/// collide with the function body. +/// +internal static class PostgresTriggerDdlBuilder +{ + private const int MaxIdentifierBytes = 63; + + public static string GenerateCreate(CreateTriggerOperation op) + { + var predicate = TriggerDdlSupport.BuildGuardPredicate(op.Trigger, RlsPlatform.Postgres); + var message = TriggerDdlSupport.EscapedMessage(op.Trigger); + var triggerName = ValidateIdentifier(ObjectName(op.Trigger.Name), "trigger name"); + var functionName = ValidateIdentifier( + FunctionName(op.TableName, op.Trigger.Name), + "trigger guard function name" + ); + var events = string.Join( + " OR ", + TriggerDdlSupport.RequireEvents(op.Trigger).Select(TriggerDdlSupport.EventVerb) + ); + var tag = DollarTag($"{predicate}\n{message}"); + return $""" + CREATE OR REPLACE FUNCTION "{op.Schema}"."{functionName}"() + RETURNS trigger + LANGUAGE plpgsql + AS {tag} + BEGIN + IF {predicate} THEN + RAISE EXCEPTION USING MESSAGE = '{message}'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END + {tag}; + DROP TRIGGER IF EXISTS "{triggerName}" ON "{op.Schema}"."{op.TableName}"; + CREATE TRIGGER "{triggerName}" + {TriggerDdlSupport.TimingKeyword( + op.Trigger.Timing + )} {events} ON "{op.Schema}"."{op.TableName}" + FOR EACH ROW + EXECUTE FUNCTION "{op.Schema}"."{functionName}"() + """; + } + + public static string GenerateDrop(DropTriggerOperation op) => + $""" + DROP TRIGGER IF EXISTS "{ObjectName(op.TriggerName)}" ON "{op.Schema}"."{op.TableName}"; + DROP FUNCTION IF EXISTS "{op.Schema}"."{FunctionName(op.TableName, op.TriggerName)}"() + """; + + private static string ObjectName(string triggerName) => + $"{TriggerDdlSupport.ManagedTriggerPrefix}{triggerName}"; + + private static string FunctionName(string tableName, string triggerName) => + $"{tableName}_{triggerName}{TriggerDdlSupport.GuardFunctionSuffix}"; + + /// + /// PostgreSQL silently truncates identifiers to 63 bytes, which would + /// desynchronise create/drop/inspect names -- fail loudly instead. + /// + private static string ValidateIdentifier(string name, string description) => + Encoding.UTF8.GetByteCount(name) <= MaxIdentifierBytes + ? name + : throw new InvalidOperationException( + $"PostgreSQL {description} '{name}' exceeds the 63-byte identifier limit" + ); + + /// + /// Picks a dollar-quote tag that does not occur in the function body so + /// user-supplied predicates/messages can never terminate the quoting. + /// + private static string DollarTag(string body) + { + var suffix = 0; + var tag = "$trigger_guard$"; + while (body.Contains(tag, StringComparison.Ordinal)) + { + suffix++; + tag = $"$trigger_guard{suffix}$"; + } + return tag; + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresTriggerSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresTriggerSchemaInspector.cs new file mode 100644 index 00000000..56852b57 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresTriggerSchemaInspector.cs @@ -0,0 +1,88 @@ +namespace Nimblesite.DataProvider.Migration.Postgres; + +// Implements [MIG-TRIGGER-PG] read-back from +// docs/specs/declarative-triggers-spec.md (GitHub issue 82). + +/// +/// Reads migration-managed triggers back from +/// information_schema.triggers (one row per event) grouped by trigger +/// name so the diff can match declarative trigger guards by name. Only +/// usr_-prefixed triggers are read: triggers created outside the +/// migration tool (e.g. Sync change tracking) stay invisible to the diff so +/// destructive runs never drop them. +/// +internal static class PostgresTriggerSchemaInspector +{ + public static IReadOnlyList Inspect( + NpgsqlConnection connection, + string schemaName, + string tableName + ) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT trigger_name, event_manipulation, action_timing, action_orientation + FROM information_schema.triggers + WHERE event_object_schema = @schema AND event_object_table = @table + AND trigger_name LIKE @managedPattern ESCAPE '\' + ORDER BY trigger_name + """; + command.Parameters.AddWithValue("@schema", schemaName); + command.Parameters.AddWithValue("@table", tableName); + command.Parameters.AddWithValue( + "@managedPattern", + $"{TriggerDdlSupport.ManagedTriggerPrefix.Replace("_", "\\_", StringComparison.Ordinal)}%" + ); + + var rows = new List(); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + rows.Add( + new PostgresTriggerRow( + Name: reader.GetString(0), + Event: reader.GetString(1), + Timing: reader.GetString(2), + Orientation: reader.GetString(3) + ) + ); + } + } + + return rows.GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToTrigger) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static TriggerDefinition ToTrigger(IGrouping group) => + new() + { + // Strip the managed prefix: the model holds the declared name. + Name = group.Key[TriggerDdlSupport.ManagedTriggerPrefix.Length..], + Timing = group.Any(r => r.Timing.Equals("AFTER", StringComparison.OrdinalIgnoreCase)) + ? TriggerTiming.After + : TriggerTiming.Before, + ForEachRow = group.All(r => + r.Orientation.Equals("ROW", StringComparison.OrdinalIgnoreCase) + ), + Events = group.Select(r => ToEvent(r.Event)).Distinct().ToList(), + }; + + private static TriggerEvent ToEvent(string eventManipulation) => + eventManipulation.ToUpperInvariant() switch + { + "INSERT" => TriggerEvent.Insert, + "UPDATE" => TriggerEvent.Update, + _ => TriggerEvent.Delete, + }; +} + +/// One information_schema.triggers row. +internal sealed record PostgresTriggerRow( + string Name, + string Event, + string Timing, + string Orientation +); diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/GlobalUsings.cs index f38d7963..f120e9fa 100644 --- a/Migration/Nimblesite.DataProvider.Migration.SQLite/GlobalUsings.cs +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/GlobalUsings.cs @@ -3,6 +3,10 @@ global using Microsoft.Data.Sqlite; global using Microsoft.Extensions.Logging; global using Nimblesite.DataProvider.Migration.Core; +global using MigrationApplyResult = Outcome.Result< + bool, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; // Type aliases global using SchemaResult = Outcome.Result< Nimblesite.DataProvider.Migration.Core.SchemaDefinition, diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteDdlGenerator.cs index 15756f1f..89d0184a 100644 --- a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteDdlGenerator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteDdlGenerator.cs @@ -14,7 +14,11 @@ public static string Generate(SchemaOperation operation) => operation switch { CreateTableOperation op => GenerateCreateTable(op.Table), + RebuildTableOperation op => SqliteTableRebuild.Generate(op), AddColumnOperation op => GenerateAddColumn(op), + MakeColumnNullableOperation => throw new NotSupportedException( + "SQLite cannot relax NOT NULL on an existing column without rebuilding its table." + ), CreateIndexOperation op => GenerateCreateIndex(op), AddForeignKeyOperation => throw new NotSupportedException( "SQLite does not support adding foreign keys to existing tables. Recreate the table instead." @@ -38,14 +42,20 @@ public static string Generate(SchemaOperation operation) => ), EnableRlsOperation => SqliteRlsDdlBuilder.GenerateEnable(), CreateRlsPolicyOperation op => SqliteRlsDdlBuilder.GenerateCreatePolicy(op), + ReplaceRlsPolicyOperation op => SqliteRlsDdlBuilder.GenerateReplacePolicy(op), + AlterRlsPolicyOperation => throw new NotSupportedException( + "SQLite cannot alter an RLS policy in place; SchemaDiff emits ReplaceRlsPolicyOperation for predicate changes." + ), DropRlsPolicyOperation op => SqliteRlsDdlBuilder.GenerateDropPolicy(op), DisableRlsOperation op => SqliteRlsDdlBuilder.GenerateDisable(op), + CreateTriggerOperation op => SqliteTriggerDdlBuilder.GenerateCreate(op), + DropTriggerOperation op => SqliteTriggerDdlBuilder.GenerateDrop(op), _ => throw new NotSupportedException( $"Unknown operation type: {operation.GetType().Name}" ), }; - private static string GenerateCreateTable(TableDefinition table) + internal static string GenerateCreateTable(TableDefinition table) { var sb = new StringBuilder(); sb.Append(CultureInfo.InvariantCulture, $"CREATE TABLE IF NOT EXISTS [{table.Name}] ("); @@ -154,7 +164,7 @@ private static string GenerateAddColumn(AddColumnOperation op) return $"ALTER TABLE [{op.TableName}] ADD COLUMN {colDef}"; } - private static string GenerateCreateIndex(CreateIndexOperation op) + internal static string GenerateCreateIndex(CreateIndexOperation op) { var unique = op.Index.IsUnique ? "UNIQUE " : ""; // Expression indexes use Expressions verbatim, column indexes quote column names diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteMigrationApplier.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteMigrationApplier.cs new file mode 100644 index 00000000..7b1c67a0 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteMigrationApplier.cs @@ -0,0 +1,275 @@ +namespace Nimblesite.DataProvider.Migration.SQLite; + +/// +/// Applies migration operations to SQLite. Constraint changes SQLite cannot ALTER +/// (nullability, foreign keys, checks, unique constraints, dropped columns) are +/// replaced by one per table, placed before any +/// other operation on that table. Implements [MIG-SQLITE-REBUILD]. +/// +public static class SqliteMigrationApplier +{ + /// + /// Plan table rebuilds, then apply the operations. Foreign keys are turned off + /// while rebuilding and foreign keys are checked before committing. + /// + public static MigrationApplyResult Apply( + SqliteConnection connection, + SchemaDefinition desired, + IReadOnlyList operations, + MigrationOptions options, + ILogger? logger = null + ) + { + try + { + var planned = Plan(connection, desired, operations); + var rebuilt = planned + .OfType() + .Select(r => r.Table.Name) + .ToList(); + logger?.LogInformation("SQLite table rebuilds planned: {Count}", rebuilt.Count); + return rebuilt.Count == 0 + ? MigrationRunner.Apply( + connection, + planned, + SqliteDdlGenerator.Generate, + options, + logger + ) + : ApplyWithoutForeignKeys(connection, planned, options, logger); + } + catch (Exception ex) + { + logger?.LogError(ex, "SQLite migration apply failed"); + return new MigrationApplyResult.Error( + MigrationError.FromException(ex) + ); + } + } + + private static MigrationApplyResult ApplyWithoutForeignKeys( + SqliteConnection connection, + IReadOnlyList planned, + MigrationOptions options, + ILogger? logger + ) + { + var enabled = Scalar(connection, "PRAGMA foreign_keys") is 1L; + Execute(connection, "PRAGMA foreign_keys = OFF"); + try + { + return MigrationRunner.Apply( + connection, + planned, + SqliteDdlGenerator.Generate, + options, + logger, + verifyBeforeCommit: () => ForeignKeysValid(connection, logger) + ); + } + finally + { + Execute(connection, enabled ? "PRAGMA foreign_keys = ON" : "PRAGMA foreign_keys = OFF"); + } + } + + // Implements [MIG-SQLITE-REBUILD]: also check referencing tables that were + // not rebuilt, while the migration transaction can still roll back. + private static bool ForeignKeysValid(SqliteConnection connection, ILogger? logger) + { + if (Scalar(connection, "PRAGMA foreign_key_check") is null) + { + return true; + } + logger?.LogError("SQLite foreign key validation failed before commit"); + return false; + } + + internal static IReadOnlyList Plan( + SqliteConnection connection, + SchemaDefinition desired, + IReadOnlyList operations + ) + { + var rebuilds = operations + .Where(NeedsRebuild) + .Select(TableOf) + .OfType() + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(table => Rebuild(connection, desired, table, operations)) + .OfType() + .ToDictionary(r => r.Table.Name, StringComparer.OrdinalIgnoreCase); + var planned = new List(); + var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var op in operations) + { + if (TableOf(op) is not { } table || !rebuilds.TryGetValue(table, out var rebuild)) + { + planned.Add(op); + continue; + } + if (emitted.Add(table)) + { + planned.Add(rebuild); + } + if (!IsAbsorbed(op)) + { + planned.Add(op); + } + } + return planned; + } + + private static RebuildTableOperation? Rebuild( + SqliteConnection connection, + SchemaDefinition desired, + string table, + IReadOnlyList operations + ) + { + var target = desired.Tables.FirstOrDefault(t => Same(t.Name, table)); + if ( + target is null + || SqliteSchemaInspector.InspectTable(connection, table) + is not TableResult.Ok live + ) + { + return null; + } + var onTable = operations.Where(op => TableOf(op) is { } t && Same(t, table)).ToList(); + var dropped = onTable.OfType().Select(d => d.ColumnName).ToList(); + var kept = live.Value.Columns.Where(c => + !target.Columns.Any(t => Same(t.Name, c.Name)) && !dropped.Any(d => Same(d, c.Name)) + ); + var shape = target with + { + Name = live.Value.Name, + Columns = [.. target.Columns, .. kept], + ForeignKeys = ForeignKeysAfterChanges(live.Value, onTable), + }; + return new RebuildTableOperation( + Table: shape, + CopyColumns: + [ + .. live + .Value.Columns.Select(c => c.Name) + .Where(c => shape.Columns.Any(s => Same(s.Name, c))), + ], + DependentObjectSql: DependentObjectSql(connection, live.Value.Name, target), + Destructive: onTable.Any(op => op is DropColumnOperation or DropForeignKeyOperation) + ); + } + + // Implements [MIG-SQLITE-REBUILD]: rebuilding must not introduce drops that + // were excluded by the additive diff or destructive-operation gate. + private static IReadOnlyList ForeignKeysAfterChanges( + TableDefinition live, + IReadOnlyList operations + ) => + [ + .. live.ForeignKeys.Where(fk => + !operations + .OfType() + .Any(drop => fk.Name is { } name && Same(name, drop.ConstraintName)) + && !operations + .OfType() + .Any(add => + SchemaDiff.ForeignKeyRelationshipsMatch( + current: fk, + desired: add.ForeignKey + ) + ) + ), + .. operations.OfType().Select(add => add.ForeignKey), + ]; + + // Indexes declared on the target are recreated from the definition; every + // other index and trigger on the table is replayed from sqlite_master. + private static List DependentObjectSql( + SqliteConnection connection, + string table, + TableDefinition target + ) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT name, sql FROM sqlite_master + WHERE type IN ('index', 'trigger') AND tbl_name = $table AND sql IS NOT NULL + ORDER BY type, name + """; + command.Parameters.AddWithValue("$table", table); + using var reader = command.ExecuteReader(); + var sql = new List(); + while (reader.Read()) + { + if (!target.Indexes.Any(i => Same(i.Name, reader.GetString(0)))) + { + sql.Add(reader.GetString(1)); + } + } + return sql; + } + + private static bool NeedsRebuild(SchemaOperation op) => + op + is MakeColumnNullableOperation + or AddForeignKeyOperation + or AddCheckConstraintOperation + or AddUniqueConstraintOperation + or DropColumnOperation + or DropForeignKeyOperation + or DropCheckConstraintOperation; + + // The rebuild creates every declared column and index, so these are subsumed. + private static bool IsAbsorbed(SchemaOperation op) => + NeedsRebuild(op) || op is AddColumnOperation or CreateIndexOperation; + + private static string? TableOf(SchemaOperation op) => + op switch + { + AddColumnOperation o => o.TableName, + MakeColumnNullableOperation o => o.TableName, + CreateIndexOperation o => o.TableName, + AddForeignKeyOperation o => o.TableName, + AddCheckConstraintOperation o => o.TableName, + AddUniqueConstraintOperation o => o.TableName, + DropColumnOperation o => o.TableName, + DropIndexOperation o => o.TableName, + DropForeignKeyOperation o => o.TableName, + DropCheckConstraintOperation o => o.TableName, + _ => TableOfPolicyOrTrigger(op), + }; + + private static string? TableOfPolicyOrTrigger(SchemaOperation op) => + op switch + { + EnableRlsOperation o => o.TableName, + EnableForceRlsOperation o => o.TableName, + CreateRlsPolicyOperation o => o.TableName, + AlterRlsPolicyOperation o => o.TableName, + ReplaceRlsPolicyOperation o => o.TableName, + DropRlsPolicyOperation o => o.TableName, + DisableRlsOperation o => o.TableName, + DisableForceRlsOperation o => o.TableName, + CreateTriggerOperation o => o.TableName, + DropTriggerOperation o => o.TableName, + _ => null, + }; + + private static bool Same(string left, string right) => + string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + + private static object? Scalar(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static void Execute(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsDdlBuilder.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsDdlBuilder.cs index f90ea1d1..fff6caa0 100644 --- a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsDdlBuilder.cs +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsDdlBuilder.cs @@ -16,16 +16,17 @@ internal static class SqliteRlsDdlBuilder public static string GenerateEnable() => "CREATE TABLE IF NOT EXISTS [__rls_context] ([current_user_id] TEXT NOT NULL)"; - public static string GenerateCreatePolicy(CreateRlsPolicyOperation op) - { - var ddl = new List(); - AddRestrictiveWarning(op.Policy, ddl); - AddInsertTrigger(op, ddl); - AddUpdateTrigger(op, ddl); - AddDeleteTrigger(op, ddl); - AddSecureView(op, ddl); - return string.Join(";\n", ddl); - } + public static string GenerateCreatePolicy(CreateRlsPolicyOperation op) => + PolicyDdl(op.Policy, op.TableName); + + // Implements [RLS-DIFF] (issue #98): SQLite triggers and views cannot be + // altered in place, so a changed policy is dropped and recreated. Runs in + // the migration transaction (ReplaceRlsPolicyOperation). + public static string GenerateReplacePolicy(ReplaceRlsPolicyOperation op) => + string.Join( + ";\n", + DropObjects(op.Policy.Name, op.TableName).Append(PolicyDdl(op.Policy, op.TableName)) + ); public static string GenerateDropPolicy(DropRlsPolicyOperation op) => string.Join( @@ -37,6 +38,26 @@ public static string GenerateDropPolicy(DropRlsPolicyOperation op) => ) ); + private static IEnumerable DropObjects(string policyName, string tableName) + { + foreach (var sqlOp in Operations().Where(sqlOp => sqlOp != "select")) + { + yield return $"DROP TRIGGER IF EXISTS [{TriggerName(sqlOp, policyName, tableName)}]"; + } + yield return $"DROP VIEW IF EXISTS [{tableName}_secure]"; + } + + private static string PolicyDdl(RlsPolicyDefinition policy, string tableName) + { + var ddl = new List(); + AddRestrictiveWarning(policy, ddl); + AddInsertTrigger(policy, tableName, ddl); + AddUpdateTrigger(policy, tableName, ddl); + AddDeleteTrigger(policy, tableName, ddl); + AddSecureView(policy, tableName, ddl); + return string.Join(";\n", ddl); + } + public static string GenerateDisable(DisableRlsOperation op) => $"DROP VIEW IF EXISTS [{op.TableName}_secure]"; @@ -48,56 +69,74 @@ private static void AddRestrictiveWarning(RlsPolicyDefinition policy, List ddl) + private static void AddInsertTrigger( + RlsPolicyDefinition policy, + string tableName, + List ddl + ) { - if (Applies(op.Policy, RlsOperation.Insert) && HasText(op.Policy.WithCheckLql)) + if (Applies(policy, RlsOperation.Insert) && HasText(policy.WithCheckLql)) { - ddl.Add(Trigger(op, "insert", "INSERT", "NEW", op.Policy.WithCheckLql!)); + ddl.Add(Trigger(policy, tableName, "insert", "INSERT", "NEW", policy.WithCheckLql!)); } } - private static void AddUpdateTrigger(CreateRlsPolicyOperation op, List ddl) + private static void AddUpdateTrigger( + RlsPolicyDefinition policy, + string tableName, + List ddl + ) { - if (Applies(op.Policy, RlsOperation.Update) && HasText(op.Policy.WithCheckLql)) + if (Applies(policy, RlsOperation.Update) && HasText(policy.WithCheckLql)) { - ddl.Add(Trigger(op, "update", "UPDATE", "NEW", op.Policy.WithCheckLql!)); + ddl.Add(Trigger(policy, tableName, "update", "UPDATE", "NEW", policy.WithCheckLql!)); } } - private static void AddDeleteTrigger(CreateRlsPolicyOperation op, List ddl) + private static void AddDeleteTrigger( + RlsPolicyDefinition policy, + string tableName, + List ddl + ) { - if (Applies(op.Policy, RlsOperation.Delete) && HasText(op.Policy.UsingLql)) + if (Applies(policy, RlsOperation.Delete) && HasText(policy.UsingLql)) { - ddl.Add(Trigger(op, "delete", "DELETE", "OLD", op.Policy.UsingLql!)); + ddl.Add(Trigger(policy, tableName, "delete", "DELETE", "OLD", policy.UsingLql!)); } } - private static void AddSecureView(CreateRlsPolicyOperation op, List ddl) + private static void AddSecureView( + RlsPolicyDefinition policy, + string tableName, + List ddl + ) { - if (Applies(op.Policy, RlsOperation.Select) && HasText(op.Policy.UsingLql)) + if (Applies(policy, RlsOperation.Select) && HasText(policy.UsingLql)) { - var predicate = Translate(op.Policy.UsingLql!, op.Policy.Name); + var predicate = Translate(policy.UsingLql!, policy.Name); ddl.Add( - $"CREATE VIEW IF NOT EXISTS [{op.TableName}_secure] AS SELECT * FROM [{op.TableName}] WHERE {predicate}" + $"DROP VIEW IF EXISTS [{tableName}_secure];\n" + + $"CREATE VIEW [{tableName}_secure] AS SELECT {SqliteRlsViewMetadata.Encode(policyName: policy.Name)} * FROM [{tableName}] WHERE {predicate}" ); } } private static string Trigger( - CreateRlsPolicyOperation op, + RlsPolicyDefinition policy, + string tableName, string sqlOp, string verb, string rowAlias, string lql ) { - var predicate = PrefixRowColumns(Translate(lql, op.Policy.Name), rowAlias); - var name = TriggerName(sqlOp, op.Policy.Name, op.TableName); + var predicate = PrefixRowColumns(Translate(lql, policy.Name), rowAlias); + var name = TriggerName(sqlOp, policy.Name, tableName); return $""" CREATE TRIGGER IF NOT EXISTS [{name}] - BEFORE {verb} ON [{op.TableName}] + BEFORE {verb} ON [{tableName}] BEGIN - SELECT RAISE(ABORT, 'RLS-SQLITE: access denied [{op.Policy.Name}]') + SELECT RAISE(ABORT, 'RLS-SQLITE: access denied [{policy.Name}]') WHERE NOT ({predicate}); END """; diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsPredicateReader.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsPredicateReader.cs new file mode 100644 index 00000000..51a27abc --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsPredicateReader.cs @@ -0,0 +1,172 @@ +using System.Collections.Immutable; +using SqlParser; +using SqlParser.Ast; +using SqlParser.Dialects; +using SqlParser.Tokens; + +namespace Nimblesite.DataProvider.Migration.SQLite; + +// Implements [RLS-DIFF] SQLite predicate extraction. The stored secure-view +// WHERE clause and trigger-body predicate are read back with the official +// SQL parser (SqlParserCS, SQLiteDialect) so an existing policy's USING / +// WITH CHECK predicate can be compared against the desired one. The trigger +// body is located with the same parser's tokenizer because SqlParserCS 0.6.5 +// cannot parse CREATE TRIGGER statements as a whole. + +internal static class SqliteRlsPredicateReader +{ + /// + /// Read the WHERE predicate of the table's {tableName}_secure view. + /// Returns null when the view does not exist or its predicate could not be + /// extracted; the returned Exists value distinguishes the two cases. + /// + public static (bool Exists, string? Predicate, string? PolicyName) ReadSecureView( + SqliteConnection connection, + string tableName + ) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'view' AND name = @name"; + command.Parameters.AddWithValue("@name", $"{tableName}_secure"); + return command.ExecuteScalar() is string sql + ? (true, ViewPredicate(sql), SqliteRlsViewMetadata.ReadPolicyName(sql: sql)) + : (false, null, null); + } + + /// + /// Extract the RAISE-guard predicate from a stored RLS trigger, with the + /// NEW./OLD. row alias stripped so the text matches the plain predicate + /// the policy declares. Null when the trigger is not in the generated + /// shape or its body cannot be parsed. + /// + public static string? TriggerPredicate(string triggerSql) => + SliceTriggerBody(triggerSql) is not { } body ? null : GuardPredicate(body); + + private static string? ViewPredicate(string viewSql) + { + try + { + var statements = new Parser().ParseSql(viewSql, new SQLiteDialect()); + return + statements.Count == 1 + && statements[0] is Statement.CreateView view + && view.Query.Query.Body is SetExpression.SelectExpression body + ? body.Select.Selection?.ToSql() + : null; + } + catch (ParserException) + { + return null; + } + } + + private static string? GuardPredicate(string bodySql) + { + try + { + var statements = new Parser().ParseSql(bodySql, new SQLiteDialect()); + return + statements.Count == 1 + && statements[0] is Statement.Select select + && select.Query.Body is SetExpression.SelectExpression body + ? GuardedPredicate(body.Select.Selection) + : null; + } + catch (ParserException) + { + return null; + } + } + + // The generated body is `SELECT RAISE(ABORT, ...) WHERE NOT ()`. + private static string? GuardedPredicate(Expression? selection) => + selection is Expression.UnaryOp { Op: UnaryOperator.Not } not + ? Unwrap(not.Expression) + : null; + + private static string? Unwrap(Expression expression) + { + var inner = expression is Expression.Nested nested ? nested.Expression : expression; + return inner is null ? null : StripRowAlias(inner).ToSql(); + } + + private static Expression StripRowAlias(Expression expression) => + expression is Expression.CompoundIdentifier c + && c.Idents.Count > 1 + && IsRowAlias(c.Idents[0]) + ? c with + { + Idents = [.. c.Idents.Skip(1)], + } + : expression is Expression.BinaryOp binary + ? binary with + { + Left = StripRowAlias(binary.Left), + Right = StripRowAlias(binary.Right), + } + : expression is Expression.UnaryOp unary + ? unary with + { + Expression = StripRowAlias(unary.Expression), + } + : expression is Expression.Nested nested ? StripRowAlias(nested.Expression) + : StripNullChecks(expression); + + private static Expression StripNullChecks(Expression expression) => + expression is Expression.IsNull check + ? check with + { + Expression = StripRowAlias(check.Expression), + } + : expression is Expression.IsNotNull nonNull + ? nonNull with + { + Expression = StripRowAlias(nonNull.Expression), + } + : expression; + + // Only the generated unquoted NEW./OLD. aliases are removed; a column + // literally named "new" is always bracket-quoted and therefore kept. + private static bool IsRowAlias(Ident ident) => + ident.QuoteStyle is null && ident.Value is "NEW" or "OLD"; + + /// + /// Return the trigger body using SQL keyword tokens and their source locations. + /// + private static string? SliceTriggerBody(string triggerSql) + { + try + { + var words = new Tokenizer(unescape: false) + .Tokenize(sql: triggerSql, dialect: new SQLiteDialect()) + .OfType() + .ToImmutableArray(); + return SliceBody(sql: triggerSql, words: words); + } + catch (TokenizeException) + { + return null; + } + } + + private static string? SliceBody(string sql, ImmutableArray words) + { + var begin = words.FirstOrDefault(word => word.Keyword == Keyword.BEGIN); + var end = words.LastOrDefault(word => word.Keyword == Keyword.END); + return begin is { } first && end is { } last + ? sql[ + (SourceOffset(sql: sql, location: first.Location) + 5)..SourceOffset( + sql: sql, + location: last.Location + ) + ] + .Trim() + .TrimEnd(';') + : null; + } + + private static int SourceOffset(string sql, Location location) => + sql.Split('\n').Take(Convert.ToInt32(location.Line) - 1).Sum(line => line.Length + 1) + + Convert.ToInt32(location.Column) + - 1; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsSchemaInspector.cs index 0add70a2..10d9ef42 100644 --- a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsSchemaInspector.cs +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsSchemaInspector.cs @@ -1,92 +1,141 @@ namespace Nimblesite.DataProvider.Migration.SQLite; -// Implements [RLS-DIFF] SQLite trigger reverse-map support. +// Implements [RLS-DIFF] SQLite trigger reverse-map support. Trigger bodies and +// the secure view are parsed by so the +// inspected policies carry the live USING / WITH CHECK predicates; SchemaDiff +// can then detect and replace a changed predicate (issue #98). internal static class SqliteRlsSchemaInspector { public static RlsPolicySetDefinition? Inspect(SqliteConnection connection, string tableName) { - var triggers = ReadTriggerNames(connection, tableName); - if (triggers.Count == 0) - { - return null; - } - + var triggers = SqliteTriggerNames.ReadDefinitions( + connection, + tableName, + $"rls_%_{tableName}" + ); + var secureView = SqliteRlsPredicateReader.ReadSecureView(connection, tableName); var policies = triggers - .Select(t => ToTriggerPolicy(t, tableName)) - .OfType() - .GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase) - .Select(ToPolicy) - .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .SelectMany(trigger => + SqliteTriggerNames.Parse( + trigger.Name, + "rls_", + tableName, + SqliteTriggerNames.DmlEventTokens + ) + is { } parsed + ? new[] { (Trigger: trigger, Parsed: parsed) } + : [] + ) + .GroupBy(item => item.Parsed.BaseName, StringComparer.OrdinalIgnoreCase) + .Select(group => + ToPolicy( + group.Key, + group, + secureView.PolicyName is null || secureView.PolicyName == group.Key + ? secureView + : (false, null, null) + ) + ) + .OrderBy(policy => policy.Name, StringComparer.OrdinalIgnoreCase) .ToList(); - return policies.Count == 0 ? null : new RlsPolicySetDefinition { Policies = policies }; - } - - private static List ReadTriggerNames(SqliteConnection connection, string tableName) - { - using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT name FROM sqlite_master - WHERE type = 'trigger' AND tbl_name = @table AND name LIKE @pattern - ORDER BY name - """; - command.Parameters.AddWithValue("@table", tableName); - command.Parameters.AddWithValue("@pattern", $"rls_%_{tableName}"); - using var reader = command.ExecuteReader(); - var names = new List(); - while (reader.Read()) - { - names.Add(reader.GetString(0)); - } - return names; - } - - private static SqliteRlsTriggerPolicy? ToTriggerPolicy(string name, string tableName) - { - var suffix = $"_{tableName}"; if ( - !name.StartsWith("rls_", StringComparison.Ordinal) - || !name.EndsWith(suffix, StringComparison.Ordinal) + secureView.Exists + && !policies.Any(policy => + policy.Operations.Contains(RlsOperation.Select) + || policy.Operations.Contains(RlsOperation.All) + ) ) { - return null; + policies.Add( + ToPolicy( + name: secureView.PolicyName ?? $"{tableName}_secure", + group: [], + secureView: secureView + ) + ); } - var body = name[4..^suffix.Length]; - var operation = ReadOperation(body); - return operation is null - ? null - : new SqliteRlsTriggerPolicy(body[(operation.SqlName.Length + 1)..], operation); + return policies.Count == 0 ? null : new RlsPolicySetDefinition { Policies = policies }; } - private static SqliteRlsOperationName? ReadOperation(string body) + private static RlsPolicyDefinition ToPolicy( + string name, + IEnumerable<(ManagedTriggerDefinition Trigger, ParsedTriggerName Parsed)> group, + (bool Exists, string? Predicate, string? PolicyName) secureView + ) { - foreach (var op in OperationNames()) + var members = group.ToList(); + var (usingSql, withCheckSql) = ExtractPredicates(members, secureView); + var operations = members + .Select(member => ToOperation(member.Parsed.EventToken)) + .Concat(secureView.Exists ? [RlsOperation.Select] : []) + .Distinct() + .OrderBy(operation => (int)operation) + .ToList(); + return new RlsPolicyDefinition { - if (body.StartsWith($"{op.SqlName}_", StringComparison.Ordinal)) - { - return op; - } - } - return null; + Name = name, + Operations = CoversAllOperations(operations) ? [RlsOperation.All] : operations, + UsingSql = usingSql, + WithCheckSql = withCheckSql, + }; } - private static RlsPolicyDefinition ToPolicy(IGrouping group) => - new() + // A generated ALL policy materialises as the secure view plus insert, + // update, and delete triggers. Predicates are attached only when every + // managed object parsed consistently; unreadable or conflicting predicates + // stay null, which SchemaDiff treats as unverified drift and reconciles. + private static (string? Using, string? WithCheck) ExtractPredicates( + List<(ManagedTriggerDefinition Trigger, ParsedTriggerName Parsed)> members, + (bool Exists, string? Predicate, string? PolicyName) secureView + ) + { + var predicates = members + .Select(member => + ( + Using: member.Parsed.EventToken == "delete", + Sql: SqliteRlsPredicateReader.TriggerPredicate(triggerSql: member.Trigger.Sql) + ) + ) + .Concat(secureView.Exists ? [(Using: true, Sql: secureView.Predicate)] : []) + .ToArray(); + var clauses = predicates.ToLookup(predicate => predicate.Using, predicate => predicate.Sql); + var usingSql = clauses[true].ToArray(); + var withCheckSql = clauses[false].ToArray(); + return + predicates.Any(predicate => predicate.Sql is null) + || usingSql.Any(sql => + !RlsPolicyPredicates.SameSql( + left: usingSql[0], + right: sql, + platform: RlsPlatform.Sqlite + ) + ) + || withCheckSql.Any(sql => + !RlsPolicyPredicates.SameSql( + left: withCheckSql[0], + right: sql, + platform: RlsPlatform.Sqlite + ) + ) + ? (null, null) + : (usingSql.FirstOrDefault(), withCheckSql.FirstOrDefault()); + } + + private static bool CoversAllOperations(List operations) => + operations.Count == 4 + && operations.Contains(RlsOperation.Select) + && operations.Contains(RlsOperation.Insert) + && operations.Contains(RlsOperation.Update) + && operations.Contains(RlsOperation.Delete); + + private static RlsOperation ToOperation(string eventToken) => + eventToken switch { - Name = group.Key, - Operations = group.Select(p => p.Operation.RlsOperation).Distinct().ToList(), + "insert" => RlsOperation.Insert, + "update" => RlsOperation.Update, + _ => RlsOperation.Delete, }; - - private static IEnumerable OperationNames() => - [ - new("insert", RlsOperation.Insert), - new("update", RlsOperation.Update), - new("delete", RlsOperation.Delete), - ]; } - -internal sealed record SqliteRlsTriggerPolicy(string Name, SqliteRlsOperationName Operation); - -internal sealed record SqliteRlsOperationName(string SqlName, RlsOperation RlsOperation); diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsViewMetadata.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsViewMetadata.cs new file mode 100644 index 00000000..0b117220 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteRlsViewMetadata.cs @@ -0,0 +1,36 @@ +using SqlParser; +using SqlParser.Dialects; +using SqlParser.Tokens; + +namespace Nimblesite.DataProvider.Migration.SQLite; + +// Implements [RLS-DIFF]: a SELECT-only policy has no trigger carrying its name. +internal static class SqliteRlsViewMetadata +{ + private const string Prefix = "RLS-SQLITE-POLICY:"; + + internal static string Encode(string policyName) => + $"/*{Prefix}{Convert.ToHexString(Encoding.UTF8.GetBytes(policyName))}*/"; + + internal static string? ReadPolicyName(string sql) + { + try + { + var metadata = new Tokenizer(unescape: false) + .Tokenize(sql: sql, dialect: new SQLiteDialect()) + .OfType() + .Where(token => token.WhitespaceKind == WhitespaceKind.MultilineComment) + .Select(token => token.Value) + .FirstOrDefault(value => + value?.StartsWith(Prefix, StringComparison.Ordinal) == true + ); + return metadata is null + ? null + : Encoding.UTF8.GetString(Convert.FromHexString(metadata[Prefix.Length..])); + } + catch (Exception error) when (error is TokenizeException or FormatException) + { + return null; + } + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaInspector.cs index 536b8a9e..586e907a 100644 --- a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaInspector.cs +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaInspector.cs @@ -254,6 +254,7 @@ SELECT sql FROM sqlite_master ForeignKeys = foreignKeys.AsReadOnly(), PrimaryKey = primaryKey, RowLevelSecurity = SqliteRlsSchemaInspector.Inspect(connection, tableName), + Triggers = SqliteTriggerSchemaInspector.Inspect(connection, tableName), } ); } @@ -279,6 +280,7 @@ public static PortableType SqliteTypeToPortable(string sqliteType) "REAL" => new DoubleType(), "TEXT" => new TextType(), "BLOB" => new BlobType(), + "BOOLEAN" => new BooleanType(), _ when upper.Contains("INT", StringComparison.Ordinal) => new BigIntType(), _ when upper.Contains("CHAR", StringComparison.Ordinal) => new TextType(), _ when upper.Contains("TEXT", StringComparison.Ordinal) => new TextType(), diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaNormalizer.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaNormalizer.cs new file mode 100644 index 00000000..c09db3ad --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteSchemaNormalizer.cs @@ -0,0 +1,36 @@ +namespace Nimblesite.DataProvider.Migration.SQLite; + +/// +/// Rewrites a desired schema into the column types SQLite actually stores so the +/// integrity verifier compares like with like (for example UUID is stored as TEXT). +/// Implements [MIG-VERIFY-BEFORE-UP-TO-DATE]. +/// +public static class SqliteSchemaNormalizer +{ + /// + /// Map every desired column type to its SQLite round-trip type. + /// + /// The declared schema + public static SchemaResult Normalize(SchemaDefinition desired) => + new SchemaResult.Ok( + desired with + { + Tables = desired + .Tables.Select(table => + table with + { + Columns = table.Columns.Select(RoundTrip).ToList(), + } + ) + .ToList(), + } + ); + + private static ColumnDefinition RoundTrip(ColumnDefinition column) => + column with + { + Type = SqliteSchemaInspector.SqliteTypeToPortable( + SqliteDdlGenerator.PortableTypeToSqlite(column.Type) + ), + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTableRebuild.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTableRebuild.cs new file mode 100644 index 00000000..1a72ec50 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTableRebuild.cs @@ -0,0 +1,37 @@ +namespace Nimblesite.DataProvider.Migration.SQLite; + +/// +/// SQLite's documented table rebuild (https://www.sqlite.org/lang_altertable.html#otheralter): +/// create the new shape, copy rows, drop, rename, then restore indexes and triggers. +/// Callers must turn foreign_keys off first so DROP TABLE does not fire ON DELETE actions. +/// Implements [MIG-SQLITE-REBUILD]. +/// +internal static class SqliteTableRebuild +{ + internal static string Generate(RebuildTableOperation op) + { + var name = op.Table.Name; + var temp = $"{name}__rebuild"; + var columns = string.Join(", ", op.CopyColumns.Select(c => $"[{c}]")); + var indexes = op.Table.Indexes.Select(index => + SqliteDdlGenerator.GenerateCreateIndex( + new CreateIndexOperation(op.Table.Schema, name, index) + ) + ); + // legacy_alter_table stops RENAME from re-validating views (such as RLS + // *_secure views) that reference the table while it is briefly absent. + return string.Join( + ";\n", + [ + "PRAGMA legacy_alter_table = ON", + SqliteDdlGenerator.GenerateCreateTable(op.Table with { Name = temp, Indexes = [] }), + $"INSERT INTO [{temp}] ({columns}) SELECT {columns} FROM [{name}]", + $"DROP TABLE [{name}]", + $"ALTER TABLE [{temp}] RENAME TO [{name}]", + .. indexes, + .. op.DependentObjectSql, + "PRAGMA legacy_alter_table = OFF", + ] + ); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerDdlBuilder.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerDdlBuilder.cs new file mode 100644 index 00000000..e0083301 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerDdlBuilder.cs @@ -0,0 +1,56 @@ +namespace Nimblesite.DataProvider.Migration.SQLite; + +// Implements [MIG-TRIGGER-SQLITE] from docs/specs/declarative-triggers-spec.md +// (GitHub issue 82). + +/// +/// Emits SQLite DDL for declarative trigger guards. One trigger per declared +/// event, named usr_{event}_{trigger}_{table} so the inspector can +/// read the definition back by name. SQLite only supports row-level +/// triggers, so FOR EACH ROW is always emitted. +/// +internal static class SqliteTriggerDdlBuilder +{ + public static string GenerateCreate(CreateTriggerOperation op) + { + var predicate = TriggerDdlSupport.BuildGuardPredicate(op.Trigger, RlsPlatform.Sqlite); + var message = TriggerDdlSupport.EscapedMessage(op.Trigger); + return string.Join( + ";\n", + TriggerDdlSupport + .RequireEvents(op.Trigger) + .Select(triggerEvent => Trigger(op, triggerEvent, predicate, message)) + ); + } + + public static string GenerateDrop(DropTriggerOperation op) => + string.Join( + ";\n", + SqliteTriggerNames.DmlEventTokens.Select(token => + $"DROP TRIGGER IF EXISTS [{TriggerName(token, op.TriggerName, op.TableName)}]" + ) + ); + + private static string Trigger( + CreateTriggerOperation op, + TriggerEvent triggerEvent, + string predicate, + string message + ) + { + var verb = TriggerDdlSupport.EventVerb(triggerEvent); + var name = TriggerName(verb.ToLowerInvariant(), op.Trigger.Name, op.TableName); + return $""" + CREATE TRIGGER IF NOT EXISTS [{name}] + {TriggerDdlSupport.TimingKeyword(op.Trigger.Timing)} {verb} ON [{op.TableName}] + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, '{message}') + WHERE {predicate}; + END + """; + } + + private static string TriggerName(string eventToken, string triggerName, string tableName) => + $"{TriggerDdlSupport.ManagedTriggerPrefix}{eventToken}_{triggerName}_{tableName}"; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerNames.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerNames.cs new file mode 100644 index 00000000..accf52a2 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerNames.cs @@ -0,0 +1,91 @@ +namespace Nimblesite.DataProvider.Migration.SQLite; + +/// +/// Shared reading/parsing for migration-managed SQLite trigger names of the +/// form {prefix}{event}_{name}_{table}. Used by the RLS and +/// declarative trigger inspectors. +/// +internal static class SqliteTriggerNames +{ + /// DML event tokens used in managed trigger names. + public static IReadOnlyList DmlEventTokens { get; } = ["insert", "update", "delete"]; + + public static List Read(SqliteConnection connection, string tableName, string pattern) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT name FROM sqlite_master + WHERE type = 'trigger' AND tbl_name = @table AND name LIKE @pattern + ORDER BY name + """; + command.Parameters.AddWithValue("@table", tableName); + command.Parameters.AddWithValue("@pattern", pattern); + using var reader = command.ExecuteReader(); + var names = new List(); + while (reader.Read()) + { + names.Add(reader.GetString(0)); + } + return names; + } + + /// + /// Read managed triggers with their stored sqlite_master.sql text so + /// callers can inspect trigger bodies (for example RLS predicates). + /// + public static List ReadDefinitions( + SqliteConnection connection, + string tableName, + string pattern + ) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT name, sql FROM sqlite_master + WHERE type = 'trigger' AND tbl_name = @table AND name LIKE @pattern + ORDER BY name + """; + command.Parameters.AddWithValue("@table", tableName); + command.Parameters.AddWithValue("@pattern", pattern); + using var reader = command.ExecuteReader(); + var triggers = new List(); + while (reader.Read()) + { + triggers.Add(new ManagedTriggerDefinition(reader.GetString(0), reader.GetString(1))); + } + return triggers; + } + + public static ParsedTriggerName? Parse( + string name, + string prefix, + string tableName, + IReadOnlyList eventTokens + ) + { + var suffix = $"_{tableName}"; + if ( + !name.StartsWith(prefix, StringComparison.Ordinal) + || !name.EndsWith(suffix, StringComparison.Ordinal) + ) + { + return null; + } + + var body = name[prefix.Length..^suffix.Length]; + foreach (var token in eventTokens) + { + if (body.StartsWith($"{token}_", StringComparison.Ordinal)) + { + return new ParsedTriggerName(token, body[(token.Length + 1)..]); + } + } + return null; + } +} + +/// Trigger name split into its event token and base name. +internal sealed record ParsedTriggerName(string EventToken, string BaseName); + +/// A managed trigger together with its stored CREATE TRIGGER text. +internal sealed record ManagedTriggerDefinition(string Name, string Sql); diff --git a/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerSchemaInspector.cs new file mode 100644 index 00000000..74f2aec7 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SQLite/SqliteTriggerSchemaInspector.cs @@ -0,0 +1,47 @@ +namespace Nimblesite.DataProvider.Migration.SQLite; + +// Implements [MIG-TRIGGER-SQLITE] read-back from +// docs/specs/declarative-triggers-spec.md (GitHub issue 82). + +/// +/// Reads declarative trigger guards back from sqlite_master so the +/// diff can match them by name. Only migration-managed usr_ triggers +/// are considered; rls_ triggers belong to the RLS inspector. +/// +internal static class SqliteTriggerSchemaInspector +{ + public static IReadOnlyList Inspect( + SqliteConnection connection, + string tableName + ) => + SqliteTriggerNames + .Read(connection, tableName, $"{TriggerDdlSupport.ManagedTriggerPrefix}%_{tableName}") + .Select(name => + SqliteTriggerNames.Parse( + name, + TriggerDdlSupport.ManagedTriggerPrefix, + tableName, + SqliteTriggerNames.DmlEventTokens + ) + ) + .OfType() + .GroupBy(p => p.BaseName, StringComparer.OrdinalIgnoreCase) + .Select(ToTrigger) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static TriggerDefinition ToTrigger(IGrouping group) => + new() + { + Name = group.Key, + Events = group.Select(p => ToEvent(p.EventToken)).Distinct().ToList(), + }; + + private static TriggerEvent ToEvent(string eventToken) => + eventToken switch + { + "insert" => TriggerEvent.Insert, + "update" => TriggerEvent.Update, + _ => TriggerEvent.Delete, + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SqlServer/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.SqlServer/GlobalUsings.cs new file mode 100644 index 00000000..5f51928b --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SqlServer/GlobalUsings.cs @@ -0,0 +1,10 @@ +global using System.Globalization; +global using System.Text; +global using Microsoft.Data.SqlClient; +global using Microsoft.Extensions.Logging; +global using Nimblesite.DataProvider.Migration.Core; +// Type aliases +global using SchemaResult = Outcome.Result< + Nimblesite.DataProvider.Migration.Core.SchemaDefinition, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; diff --git a/Migration/Nimblesite.DataProvider.Migration.SqlServer/Nimblesite.DataProvider.Migration.SqlServer.csproj b/Migration/Nimblesite.DataProvider.Migration.SqlServer/Nimblesite.DataProvider.Migration.SqlServer.csproj new file mode 100644 index 00000000..48b1dcd1 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SqlServer/Nimblesite.DataProvider.Migration.SqlServer.csproj @@ -0,0 +1,22 @@ + + + Library + Nimblesite.DataProvider.Migration.SqlServer + Nimblesite.DataProvider.Migration.SqlServer + SQL Server DDL generator and schema inspector for Nimblesite.DataProvider.Migration.Core + $(NoWarn);CA2254;CA2100 + + + + + + + + + + + + + + + diff --git a/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerCatalogQueries.cs b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerCatalogQueries.cs new file mode 100644 index 00000000..28e1c9bb --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerCatalogQueries.cs @@ -0,0 +1,216 @@ +using System.Collections.Immutable; + +namespace Nimblesite.DataProvider.Migration.SqlServer; + +internal sealed record TableRow(string Schema, string Table); + +internal sealed record ColumnRow(string Schema, string Table, ColumnDefinition Column); + +internal sealed record KeyRow( + string Schema, + string Table, + string Name, + bool IsPrimary, + string Column +); + +internal sealed record ForeignKeyRow( + string Schema, + string Table, + string Name, + string Column, + string ReferencedSchema, + string ReferencedTable, + string ReferencedColumn, + ForeignKeyAction OnDelete, + ForeignKeyAction OnUpdate +); + +internal sealed record IndexRow( + string Schema, + string Table, + string Name, + bool IsUnique, + string? Filter, + string Column +); + +internal sealed record CheckRow( + string Schema, + string Table, + string Name, + string? Column, + string Expression +); + +/// +/// SQL Server catalog queries used by . +/// Implements [MIG-SQLSERVER]. +/// +internal static class SqlServerCatalogQueries +{ + internal static ImmutableArray Tables(SqlConnection connection) => + Read( + connection, + """ + SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_SCHEMA, TABLE_NAME + """, + r => new TableRow(r.GetString(0), r.GetString(1)) + ); + + internal static ImmutableArray Columns(SqlConnection connection) => + Read( + connection, + """ + SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, + c.CHARACTER_MAXIMUM_LENGTH, + COALESCE(CAST(c.NUMERIC_PRECISION AS INT), CAST(c.DATETIME_PRECISION AS INT)), + c.NUMERIC_SCALE, c.IS_NULLABLE, c.COLUMN_DEFAULT, + COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME)), + c.COLUMN_NAME, 'IsIdentity') + FROM INFORMATION_SCHEMA.COLUMNS c + JOIN INFORMATION_SCHEMA.TABLES t + ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME + WHERE t.TABLE_TYPE = 'BASE TABLE' + ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION + """, + r => new ColumnRow(r.GetString(0), r.GetString(1), ReadColumn(r)) + ); + + private static ColumnDefinition ReadColumn(SqlDataReader r) => + new() + { + Name = r.GetString(2), + Type = SqlServerTypeMapper.FromSqlServer( + dataType: r.GetString(3), + maxLength: NullableInt(r, 4), + precision: NullableInt(r, 5), + scale: NullableInt(r, 6) + ), + IsNullable = r.GetString(7) == "YES", + DefaultValue = r.IsDBNull(8) + ? null + : SqlExpressionText.StripOuterParens(r.GetString(8)), + IsIdentity = NullableInt(r, 9) == 1, + }; + + internal static ImmutableArray Keys(SqlConnection connection) => + Read( + connection, + """ + SELECT tc.TABLE_SCHEMA, tc.TABLE_NAME, tc.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, k.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE') + ORDER BY tc.TABLE_SCHEMA, tc.TABLE_NAME, tc.CONSTRAINT_NAME, k.ORDINAL_POSITION + """, + r => new KeyRow( + r.GetString(0), + r.GetString(1), + r.GetString(2), + r.GetString(3) == "PRIMARY KEY", + r.GetString(4) + ) + ); + + internal static ImmutableArray ForeignKeys(SqlConnection connection) => + Read( + connection, + """ + SELECT SCHEMA_NAME(fk.schema_id), OBJECT_NAME(fk.parent_object_id), fk.name, + COL_NAME(fkc.parent_object_id, fkc.parent_column_id), + SCHEMA_NAME(rt.schema_id), rt.name, + COL_NAME(fkc.referenced_object_id, fkc.referenced_column_id), + fk.delete_referential_action_desc, fk.update_referential_action_desc + FROM sys.foreign_keys fk + JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id + JOIN sys.tables rt ON rt.object_id = fk.referenced_object_id + ORDER BY 1, 2, 3, fkc.constraint_column_id + """, + r => new ForeignKeyRow( + r.GetString(0), + r.GetString(1), + r.GetString(2), + r.GetString(3), + r.GetString(4), + r.GetString(5), + r.GetString(6), + ReferentialAction(r.GetString(7)), + ReferentialAction(r.GetString(8)) + ) + ); + + internal static ImmutableArray Indexes(SqlConnection connection) => + Read( + connection, + """ + SELECT SCHEMA_NAME(t.schema_id), t.name, i.name, i.is_unique, i.filter_definition, + COL_NAME(ic.object_id, ic.column_id) + FROM sys.indexes i + JOIN sys.tables t ON t.object_id = i.object_id + JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id + WHERE i.is_primary_key = 0 AND i.is_unique_constraint = 0 AND i.type > 0 + AND ic.is_included_column = 0 + ORDER BY 1, 2, 3, ic.key_ordinal + """, + r => new IndexRow( + r.GetString(0), + r.GetString(1), + r.GetString(2), + r.GetBoolean(3), + r.IsDBNull(4) ? null : SqlExpressionText.StripOuterParens(r.GetString(4)), + r.GetString(5) + ) + ); + + internal static ImmutableArray Checks(SqlConnection connection) => + Read( + connection, + """ + SELECT SCHEMA_NAME(t.schema_id), t.name, cc.name, + COL_NAME(cc.parent_object_id, NULLIF(cc.parent_column_id, 0)), cc.definition + FROM sys.check_constraints cc + JOIN sys.tables t ON t.object_id = cc.parent_object_id + ORDER BY 1, 2, 3 + """, + r => new CheckRow( + r.GetString(0), + r.GetString(1), + r.GetString(2), + r.IsDBNull(3) ? null : r.GetString(3), + SqlExpressionText.StripOuterParens(r.GetString(4)) + ) + ); + + private static ImmutableArray Read( + SqlConnection connection, + string sql, + Func map + ) + { + using var command = new SqlCommand(sql, connection); + using var reader = command.ExecuteReader(); + var rows = ImmutableArray.CreateBuilder(); + while (reader.Read()) + { + rows.Add(map(reader)); + } + return rows.ToImmutable(); + } + + private static int? NullableInt(SqlDataReader reader, int ordinal) => + reader.IsDBNull(ordinal) + ? null + : Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture); + + private static ForeignKeyAction ReferentialAction(string description) => + description switch + { + "CASCADE" => ForeignKeyAction.Cascade, + "SET_NULL" => ForeignKeyAction.SetNull, + "SET_DEFAULT" => ForeignKeyAction.SetDefault, + _ => ForeignKeyAction.NoAction, + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerDdlGenerator.cs new file mode 100644 index 00000000..adcdc3d5 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerDdlGenerator.cs @@ -0,0 +1,210 @@ +namespace Nimblesite.DataProvider.Migration.SqlServer; + +/// +/// SQL Server DDL generator for schema operations. Constraint names default to +/// the same PK_/UQ_/FK_ names the integrity verifier expects. +/// Implements [MIG-SQLSERVER]. +/// +public static class SqlServerDdlGenerator +{ + /// + /// Generate SQL Server DDL for a schema operation. + /// + public static string Generate(SchemaOperation operation) => + operation switch + { + CreateTableOperation op => GenerateCreateTable(op.Table), + AddColumnOperation op => + $"ALTER TABLE {Table(op.Schema, op.TableName)} ADD {ColumnDef(op.Column)}", + MakeColumnNullableOperation op => MakeNullable(op), + CreateIndexOperation op => GenerateCreateIndex(op.Schema, op.TableName, op.Index), + AddForeignKeyOperation op => + $"ALTER TABLE {Table(op.Schema, op.TableName)} ADD {ForeignKey(op.TableName, op.ForeignKey)}", + AddCheckConstraintOperation op => + $"ALTER TABLE {Table(op.Schema, op.TableName)} ADD {Check(op.CheckConstraint)}", + AddUniqueConstraintOperation op => + $"ALTER TABLE {Table(op.Schema, op.TableName)} ADD {Unique(op.TableName, op.UniqueConstraint)}", + _ => GenerateDrop(operation), + }; + + private static string GenerateDrop(SchemaOperation operation) => + operation switch + { + DropTableOperation op => $"DROP TABLE IF EXISTS {Table(op.Schema, op.TableName)}", + DropColumnOperation op => + $"ALTER TABLE {Table(op.Schema, op.TableName)} DROP COLUMN {Quote(op.ColumnName)}", + DropIndexOperation op => + $"DROP INDEX IF EXISTS {Quote(op.IndexName)} ON {Table(op.Schema, op.TableName)}", + DropForeignKeyOperation op => DropConstraint( + op.Schema, + op.TableName, + op.ConstraintName + ), + DropCheckConstraintOperation op => DropConstraint( + op.Schema, + op.TableName, + op.ConstraintName + ), + _ => throw new NotSupportedException( + $"SQL Server migration does not support {operation.GetType().Name}" + ), + }; + + // ALTER COLUMN must restate the type, which the operation does not carry, + // so read the live type and collation from the catalog and run it dynamically. + private static string MakeNullable(MakeColumnNullableOperation op) + { + var table = Table(op.Schema, op.TableName); + return $$""" + DECLARE @type nvarchar(300) = ( + SELECT CASE + WHEN t.name IN ('varchar', 'char', 'varbinary', 'binary') + THEN t.name + '(' + IIF(c.max_length = -1, 'MAX', CAST(c.max_length AS nvarchar(10))) + ')' + WHEN t.name IN ('nvarchar', 'nchar') + THEN t.name + '(' + IIF(c.max_length = -1, 'MAX', CAST(c.max_length / 2 AS nvarchar(10))) + ')' + WHEN t.name IN ('decimal', 'numeric') + THEN t.name + '(' + CAST(c.precision AS nvarchar(10)) + ',' + CAST(c.scale AS nvarchar(10)) + ')' + WHEN t.name IN ('datetime2', 'time', 'datetimeoffset') + THEN t.name + '(' + CAST(c.scale AS nvarchar(10)) + ')' + ELSE t.name END + + IIF(c.collation_name IS NULL, '', ' COLLATE ' + c.collation_name) + FROM sys.columns c JOIN sys.types t ON t.user_type_id = c.user_type_id + WHERE c.object_id = OBJECT_ID({{Literal(table)}}) AND c.name = {{Literal( + op.ColumnName + )}}); + EXEC({{Literal( + $"ALTER TABLE {table} ALTER COLUMN {Quote(op.ColumnName)} " + )}} + @type + N' NULL'); + """; + } + + private static string Literal(string value) => + $"N'{value.Replace("'", "''", StringComparison.Ordinal)}'"; + + private static string GenerateCreateTable(TableDefinition table) + { + var definitions = table + .Columns.Select(column => + ColumnDef( + PrimaryKeyNullability.UsesPlatformDefault(table, column) + ? column with + { + IsNullable = false, + } + : column + ) + ) + .Concat(PrimaryKey(table)) + .Concat(table.ForeignKeys.Select(fk => ForeignKey(table.Name, fk))) + .Concat(table.UniqueConstraints.Select(uc => Unique(table.Name, uc))) + .Concat(table.CheckConstraints.Select(Check)); + var sb = new StringBuilder(); + sb.Append( + CultureInfo.InvariantCulture, + $"CREATE TABLE {Table(table.Schema, table.Name)} ({string.Join(", ", definitions)})" + ); + foreach (var index in table.Indexes) + { + sb.Append(";\n").Append(GenerateCreateIndex(table.Schema, table.Name, index)); + } + return sb.ToString(); + } + + private static IEnumerable PrimaryKey(TableDefinition table) => + table.PrimaryKey is { Columns.Count: > 0 } pk + ? + [ + $"CONSTRAINT {Quote(pk.Name ?? $"PK_{table.Name}")} PRIMARY KEY ({Columns(pk.Columns)})", + ] + : []; + + private static string ColumnDef(ColumnDefinition column) => + column.ComputedExpression is not null + ? $"{Quote(column.Name)} AS ({column.ComputedExpression}){(column.IsComputedPersisted ? " PERSISTED" : "")}" + : $"{Quote(column.Name)} {SqlServerTypeMapper.ToSqlServer(column.Type)}{ColumnSuffix(column)}"; + + private static string ColumnSuffix(ColumnDefinition column) + { + var sb = new StringBuilder(); + if (column.IsIdentity) + { + sb.Append( + CultureInfo.InvariantCulture, + $" IDENTITY({column.IdentitySeed},{column.IdentityIncrement})" + ); + } + sb.Append(column.IsNullable ? " NULL" : " NOT NULL"); + sb.Append(Default(column)); + sb.Append(column.Collation is null ? "" : $" COLLATE {column.Collation}"); + sb.Append(ColumnCheck(column)); + return sb.ToString(); + } + + private static string Default(ColumnDefinition column) => + column switch + { + { DefaultLqlExpression: not null } => throw new NotSupportedException( + $"SQL Server migration does not support LQL default on column {column.Name}" + ), + { DefaultValue: not null } => $" DEFAULT {column.DefaultValue}", + _ => "", + }; + + private static string ColumnCheck(ColumnDefinition column) => + column.CheckConstraint is null ? "" + : column.CheckConstraintName is null ? $" CHECK ({column.CheckConstraint})" + : $" CONSTRAINT {Quote(column.CheckConstraintName)} CHECK ({column.CheckConstraint})"; + + private static string GenerateCreateIndex( + string schema, + string tableName, + IndexDefinition index + ) + { + if (index.Expressions.Count > 0) + { + throw new NotSupportedException( + $"SQL Server does not support expression index {index.Name}; use a computed column" + ); + } + var unique = index.IsUnique ? "UNIQUE " : ""; + var filter = index.Filter is null ? "" : $" WHERE {index.Filter}"; + return $"CREATE {unique}INDEX {Quote(index.Name)} ON {Table(schema, tableName)} ({Columns(index.Columns)}){filter}"; + } + + private static string ForeignKey(string tableName, ForeignKeyDefinition fk) => + $"CONSTRAINT {Quote(fk.Name ?? $"FK_{tableName}_{string.Join("_", fk.Columns)}")} " + + $"FOREIGN KEY ({Columns(fk.Columns)}) " + + $"REFERENCES {Table(fk.ReferencedSchema, fk.ReferencedTable)} ({Columns(fk.ReferencedColumns)}) " + + $"ON DELETE {ForeignKeyAction(fk.OnDelete)} ON UPDATE {ForeignKeyAction(fk.OnUpdate)}"; + + private static string Unique(string tableName, UniqueConstraintDefinition uc) => + $"CONSTRAINT {Quote(uc.Name ?? $"UQ_{tableName}_{string.Join("_", uc.Columns)}")} UNIQUE ({Columns(uc.Columns)})"; + + private static string Check(CheckConstraintDefinition cc) => + $"CONSTRAINT {Quote(cc.Name)} CHECK ({cc.Expression})"; + + private static string DropConstraint(string schema, string tableName, string name) => + $"ALTER TABLE {Table(schema, tableName)} DROP CONSTRAINT {Quote(name)}"; + + private static string ForeignKeyAction(ForeignKeyAction action) => + action switch + { + Core.ForeignKeyAction.NoAction => "NO ACTION", + Core.ForeignKeyAction.Cascade => "CASCADE", + Core.ForeignKeyAction.SetNull => "SET NULL", + Core.ForeignKeyAction.SetDefault => "SET DEFAULT", + _ => throw new NotSupportedException( + $"SQL Server does not support foreign key action {action}; use NoAction" + ), + }; + + private static string Columns(IEnumerable columns) => + string.Join(", ", columns.Select(Quote)); + + private static string Table(string schema, string name) => + $"{Quote(SqlServerSchemaInspector.EffectiveSchema(schema))}.{Quote(name)}"; + + private static string Quote(string identifier) => + $"[{identifier.Replace("]", "]]", StringComparison.Ordinal)}]"; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerSchemaInspector.cs new file mode 100644 index 00000000..ba29512b --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerSchemaInspector.cs @@ -0,0 +1,148 @@ +using System.Collections.Immutable; + +namespace Nimblesite.DataProvider.Migration.SqlServer; + +/// +/// Inspects a SQL Server database and returns a SchemaDefinition covering +/// tables, columns, primary keys, unique constraints, foreign keys, indexes +/// and check constraints. Implements [MIG-SQLSERVER]. +/// +public static class SqlServerSchemaInspector +{ + /// + /// Inspect every user table in the database. + /// + /// Open SQL Server connection + /// Optional logger + public static SchemaResult Inspect(SqlConnection connection, ILogger? logger = null) + { + try + { + logger?.LogDebug("Inspecting SQL Server schema"); + var catalog = new Catalog( + Columns: SqlServerCatalogQueries.Columns(connection), + Keys: SqlServerCatalogQueries.Keys(connection), + ForeignKeys: SqlServerCatalogQueries.ForeignKeys(connection), + Indexes: SqlServerCatalogQueries.Indexes(connection), + Checks: SqlServerCatalogQueries.Checks(connection) + ); + var tables = SqlServerCatalogQueries + .Tables(connection) + .Select(t => BuildTable(t.Schema, t.Table, catalog)) + .ToList(); + logger?.LogDebug("Inspected {TableCount} SQL Server tables", tables.Count); + return new SchemaResult.Ok( + new SchemaDefinition { Name = "sqlserver", Tables = tables.AsReadOnly() } + ); + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to inspect SQL Server schema"); + return new SchemaResult.Error( + MigrationError.FromException(ex) + ); + } + } + + /// + /// Map portable default schema names (public, main, empty) to SQL Server's dbo. + /// + internal static string EffectiveSchema(string schema) => + string.IsNullOrWhiteSpace(schema) + || schema.Equals("public", StringComparison.OrdinalIgnoreCase) + || schema.Equals("main", StringComparison.OrdinalIgnoreCase) + ? "dbo" + : schema; + + private sealed record Catalog( + ImmutableArray Columns, + ImmutableArray Keys, + ImmutableArray ForeignKeys, + ImmutableArray Indexes, + ImmutableArray Checks + ); + + private static TableDefinition BuildTable(string schema, string table, Catalog catalog) + { + bool Owns(string s, string t) => s == schema && t == table; + var keys = catalog.Keys.Where(k => Owns(k.Schema, k.Table)).ToList(); + var checks = catalog.Checks.Where(c => Owns(c.Schema, c.Table)).ToList(); + return new TableDefinition + { + Schema = schema, + Name = table, + Columns = BuildColumns(catalog.Columns.Where(c => Owns(c.Schema, c.Table)), checks), + PrimaryKey = BuildPrimaryKey(keys), + UniqueConstraints = BuildUniqueConstraints(keys), + ForeignKeys = BuildForeignKeys(catalog.ForeignKeys.Where(f => Owns(f.Schema, f.Table))), + Indexes = BuildIndexes(catalog.Indexes.Where(i => Owns(i.Schema, i.Table))), + CheckConstraints = checks + .Where(c => c.Column is null) + .Select(c => new CheckConstraintDefinition + { + Name = c.Name, + Expression = c.Expression, + }) + .ToList(), + }; + } + + private static List BuildColumns( + IEnumerable rows, + List checks + ) => + rows.Select(row => + checks.FirstOrDefault(c => c.Column == row.Column.Name) is { } check + ? row.Column with + { + CheckConstraint = check.Expression, + CheckConstraintName = check.Name, + } + : row.Column + ) + .ToList(); + + private static PrimaryKeyDefinition? BuildPrimaryKey(List keys) => + keys.Where(k => k.IsPrimary).ToList() is { Count: > 0 } pk + ? new PrimaryKeyDefinition + { + Name = pk[0].Name, + Columns = pk.Select(k => k.Column).ToList(), + } + : null; + + private static List BuildUniqueConstraints(List keys) => + keys.Where(k => !k.IsPrimary) + .GroupBy(k => k.Name) + .Select(g => new UniqueConstraintDefinition + { + Name = g.Key, + Columns = g.Select(k => k.Column).ToList(), + }) + .ToList(); + + private static List BuildForeignKeys(IEnumerable rows) => + rows.GroupBy(f => f.Name) + .Select(g => new ForeignKeyDefinition + { + Name = g.Key, + Columns = g.Select(f => f.Column).ToList(), + ReferencedSchema = g.First().ReferencedSchema, + ReferencedTable = g.First().ReferencedTable, + ReferencedColumns = g.Select(f => f.ReferencedColumn).ToList(), + OnDelete = g.First().OnDelete, + OnUpdate = g.First().OnUpdate, + }) + .ToList(); + + private static List BuildIndexes(IEnumerable rows) => + rows.GroupBy(i => i.Name) + .Select(g => new IndexDefinition + { + Name = g.Key, + IsUnique = g.First().IsUnique, + Filter = g.First().Filter, + Columns = g.Select(i => i.Column).ToList(), + }) + .ToList(); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerSchemaNormalizer.cs b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerSchemaNormalizer.cs new file mode 100644 index 00000000..b76a7a90 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerSchemaNormalizer.cs @@ -0,0 +1,32 @@ +namespace Nimblesite.DataProvider.Migration.SqlServer; + +/// +/// Compares SQL Server declarations with the types its catalog can report. +/// Implements [MIG-SQLSERVER]. +/// +public static class SqlServerSchemaNormalizer +{ + /// Normalize portable types that share one SQL Server storage type. + public static SchemaResult Normalize(SchemaDefinition desired) => + new SchemaResult.Ok( + desired with + { + Tables = desired + .Tables.Select(table => + table with + { + Columns = table.Columns.Select(RoundTrip).ToList(), + } + ) + .ToList(), + } + ); + + private static ColumnDefinition RoundTrip(ColumnDefinition column) => + column.Type is JsonType or NVarCharType { MaxLength: int.MaxValue } + ? column with + { + Type = new TextType(), + } + : column; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerTypeMapper.cs b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerTypeMapper.cs new file mode 100644 index 00000000..a722decd --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.SqlServer/SqlServerTypeMapper.cs @@ -0,0 +1,123 @@ +namespace Nimblesite.DataProvider.Migration.SqlServer; + +/// +/// Maps portable types to SQL Server column types and back. Every mapping +/// round-trips so the integrity verifier sees no drift after a migration. +/// Implements [MIG-SQLSERVER]. +/// +public static class SqlServerTypeMapper +{ + /// + /// Map a portable type to its SQL Server column type. + /// + public static string ToSqlServer(PortableType type) => + type switch + { + TinyIntType => "TINYINT", + SmallIntType => "SMALLINT", + IntType => "INT", + BigIntType => "BIGINT", + DecimalType d => $"DECIMAL({d.Precision},{d.Scale})", + MoneyType => "MONEY", + SmallMoneyType => "SMALLMONEY", + FloatType => "REAL", + DoubleType => "FLOAT", + _ => ToSqlServerText(type), + }; + + private static string ToSqlServerText(PortableType type) => + type switch + { + CharType c => $"CHAR({c.Length})", + VarCharType v => $"VARCHAR({Length(v.MaxLength)})", + NCharType n => $"NCHAR({n.Length})", + NVarCharType n => $"NVARCHAR({Length(n.MaxLength)})", + TextType or JsonType => "NVARCHAR(MAX)", + XmlType => "XML", + _ => ToSqlServerOther(type), + }; + + private static string ToSqlServerOther(PortableType type) => + type switch + { + BinaryType b => $"BINARY({b.Length})", + VarBinaryType v => $"VARBINARY({Length(v.MaxLength)})", + BlobType => "VARBINARY(MAX)", + DateType => "DATE", + TimeType t => $"TIME({t.Precision})", + DateTimeType d => $"DATETIME2({d.Precision})", + DateTimeOffsetType => "DATETIMEOFFSET", + RowVersionType => "ROWVERSION", + UuidType => "UNIQUEIDENTIFIER", + BooleanType => "BIT", + GeometryType => "GEOMETRY", + GeographyType => "GEOGRAPHY", + _ => throw new NotSupportedException( + $"SQL Server migration does not support portable type {type.GetType().Name}" + ), + }; + + private static string Length(int maxLength) => + maxLength == int.MaxValue ? "MAX" : maxLength.ToString(CultureInfo.InvariantCulture); + + /// + /// Map an INFORMATION_SCHEMA column description back to a portable type. + /// + /// INFORMATION_SCHEMA.COLUMNS.DATA_TYPE + /// CHARACTER_MAXIMUM_LENGTH (-1 means MAX) + /// NUMERIC_PRECISION or DATETIME_PRECISION + /// NUMERIC_SCALE + public static PortableType FromSqlServer( + string dataType, + int? maxLength, + int? precision, + int? scale + ) => + dataType.ToUpperInvariant() switch + { + "TINYINT" => new TinyIntType(), + "SMALLINT" => new SmallIntType(), + "INT" => new IntType(), + "BIGINT" => new BigIntType(), + "DECIMAL" or "NUMERIC" => new DecimalType(precision ?? 18, scale ?? 0), + "MONEY" => new MoneyType(), + "SMALLMONEY" => new SmallMoneyType(), + "REAL" => new FloatType(), + "FLOAT" => new DoubleType(), + var other => FromSqlServerText(other, MaxLength(maxLength), precision), + }; + + private static PortableType FromSqlServerText(string dataType, int length, int? precision) => + dataType switch + { + "CHAR" => new CharType(length), + "VARCHAR" => new VarCharType(length), + "NCHAR" => new NCharType(length), + "NVARCHAR" when length == int.MaxValue => new TextType(), + "NVARCHAR" => new NVarCharType(length), + "XML" => new XmlType(), + "BINARY" => new BinaryType(length), + "VARBINARY" when length == int.MaxValue => new BlobType(), + "VARBINARY" => new VarBinaryType(length), + _ => FromSqlServerOther(dataType, precision), + }; + + private static PortableType FromSqlServerOther(string dataType, int? precision) => + dataType switch + { + "DATE" => new DateType(), + "TIME" => new TimeType(precision ?? 7), + "DATETIME2" => new DateTimeType(precision ?? 7), + "DATETIME" => new DateTimeType(3), + "DATETIMEOFFSET" => new DateTimeOffsetType(), + "TIMESTAMP" or "ROWVERSION" => new RowVersionType(), + "UNIQUEIDENTIFIER" => new UuidType(), + "BIT" => new BooleanType(), + "GEOMETRY" => new GeometryType(null), + "GEOGRAPHY" => new GeographyType(), + _ => new TextType(), + }; + + private static int MaxLength(int? maxLength) => + maxLength is null or -1 ? int.MaxValue : maxLength.Value; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateIntegrityTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateIntegrityTests.cs index 8858a201..5d79f199 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateIntegrityTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateIntegrityTests.cs @@ -283,32 +283,12 @@ private void ApplySchema(SchemaDefinition schema) ); } - private (int ExitCode, string Output) RunMigrate(string schemaPath) - { - var originalOut = Console.Out; - using var output = new StringWriter(CultureInfo.InvariantCulture); - Console.SetOut(output); - try - { - var exitCode = DataProviderMigrate.Program.Main( - args: - [ - "migrate", - "--schema", - schemaPath, - "--provider", - "postgres", - "--output", - _connectionString, - ] - ); - return (exitCode, output.ToString()); - } - finally - { - Console.SetOut(originalOut); - } - } + private (int ExitCode, string Output) RunMigrate(string schemaPath) => + MigrationCliConsole.Migrate( + schemaPath: schemaPath, + provider: "postgres", + output: _connectionString + ); private static string WriteTempSchemaFile(string contents) { diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateRlsUnsupportedTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateRlsUnsupportedTests.cs index 92e8edcc..980b20e2 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateRlsUnsupportedTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/DataProviderMigrateRlsUnsupportedTests.cs @@ -2,7 +2,7 @@ namespace Nimblesite.DataProvider.Migration.Tests; -// Tests [RLS-MSSQL] SQL Server package absence guard from docs/specs/rls-spec.md. +// Tests [RLS-MSSQL] fail-closed guard until native SQL Server RLS is implemented. /// /// CLI tests for unsupported SQL Server RLS migration attempts. @@ -25,7 +25,12 @@ public void Migrate_SqlServerProviderWithRlsSchema_ReturnsCanonicalUnsupportedEr comparisonType: StringComparison.Ordinal ); Assert.Contains( - expectedSubstring: "Nimblesite.DataProvider.Migration.SqlServer package does not exist", + expectedSubstring: "Native SQL Server RLS migrations are not yet supported", + actualString: result.Output, + comparisonType: StringComparison.Ordinal + ); + Assert.DoesNotContain( + expectedSubstring: "package does not exist", actualString: result.Output, comparisonType: StringComparison.Ordinal ); @@ -36,32 +41,12 @@ public void Migrate_SqlServerProviderWithRlsSchema_ReturnsCanonicalUnsupportedEr } } - private static (int ExitCode, string Output) RunMigrate(string schemaPath) - { - var originalOut = Console.Out; - using var output = new StringWriter(CultureInfo.InvariantCulture); - Console.SetOut(output); - try - { - var exitCode = DataProviderMigrate.Program.Main( - args: - [ - "migrate", - "--schema", - schemaPath, - "--provider", - "sqlserver", - "--output", - "unused", - ] - ); - return (exitCode, output.ToString()); - } - finally - { - Console.SetOut(originalOut); - } - } + private static (int ExitCode, string Output) RunMigrate(string schemaPath) => + MigrationCliConsole.Migrate( + schemaPath: schemaPath, + provider: "sqlserver", + output: "unused" + ); private static string WriteTempSchemaFile(string contents) { diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/DeclaredSchemaRerunTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/DeclaredSchemaRerunTests.cs new file mode 100644 index 00000000..d0110746 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/DeclaredSchemaRerunTests.cs @@ -0,0 +1,101 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [MIG-VERIFY-BEFORE-UP-TO-DATE] and [MIG-TEST-CROSS-PLATFORM]. Real schemas +// (found via `make clinical`) must verify on the first run and rerun as a no-op: +// - primary key columns left at the default nullability are NOT NULL on the platform +// - a parenthesized default such as (1) is stored without the redundant parentheses +[Collection(MigrationPlatformSuite.Name)] +public sealed record DeclaredSchemaRerunTests +{ + private readonly MigrationPostgresContainerFixture _postgres; + private readonly SqlServerContainerFixture _sqlServer; + + public DeclaredSchemaRerunTests( + MigrationPostgresContainerFixture postgres, + SqlServerContainerFixture sqlServer + ) + { + _postgres = postgres; + _sqlServer = sqlServer; + } + + private const string PrimaryKeyDefaultNullability = """ + name: pk_nullability + tables: + - name: pk_default_nullability + columns: + - name: id + type: Uuid + - name: label + type: Text + primaryKey: + columns: + - id + """; + + private const string ParenthesizedDefault = """ + name: parenthesized_default + tables: + - name: parenthesized_default + columns: + - name: id + type: Uuid + isNullable: false + - name: attempts + type: Int + isNullable: false + defaultValue: (1) + primaryKey: + columns: + - id + """; + + [Theory] + [InlineData("sqlite", PrimaryKeyDefaultNullability)] + [InlineData("postgres", PrimaryKeyDefaultNullability)] + [InlineData("sqlserver", PrimaryKeyDefaultNullability)] + [InlineData("sqlite", ParenthesizedDefault)] + [InlineData("postgres", ParenthesizedDefault)] + [InlineData("sqlserver", ParenthesizedDefault)] + public async Task Migrate_DeclaredSchema_VerifiesAndRerunsCleanly(string provider, string yaml) + { + var schemaPath = Path.GetTempFileName(); + var sqlitePath = Path.Combine(Path.GetTempPath(), $"declared_schema_{Guid.NewGuid():N}.db"); + try + { + await File.WriteAllTextAsync(schemaPath, yaml).ConfigureAwait(true); + var output = provider switch + { + "postgres" => await PostgresConnectionString().ConfigureAwait(true), + "sqlserver" => await _sqlServer + .CreateDatabaseConnectionStringAsync() + .ConfigureAwait(true), + _ => sqlitePath, + }; + AssertMigrates(provider, schemaPath, output); + AssertMigrates(provider, schemaPath, output); + } + finally + { + File.Delete(schemaPath); + File.Delete(sqlitePath); + } + } + + private async Task PostgresConnectionString() + { + using var connection = await _postgres + .CreateDatabaseAsync("declared_schema") + .ConfigureAwait(true); + return connection.ConnectionString; + } + + // All CLI calls share the Console gate, including those checking only an exit code. + private static void AssertMigrates(string provider, string schemaPath, string output) => + Assert.Equal( + 0, + MigrationCliConsole + .Migrate(schemaPath: schemaPath, provider: provider, output: output) + .ExitCode + ); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs index 3c05eaf9..d90fb25e 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs @@ -7,6 +7,10 @@ global using Nimblesite.TestSupport; global using Npgsql; global using Xunit; +global using MigrationApplyResult = Outcome.Result< + bool, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; global using MigrationApplyResultError = Outcome.Result< bool, Nimblesite.DataProvider.Migration.Core.MigrationError diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs index ef572b00..6bc7d699 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs @@ -684,7 +684,7 @@ public void LqlDefaults_Idempotent_BothPlatforms() // ASSERT 2: Table structure is EXACTLY what we defined // PostgreSQL: Verify table exists with correct columns - Assert.True(PostgresTableExists(_pgConnection, "rerunnable", "public")); + Assert.True(SchemaVerifier.PostgresTableExists(_pgConnection, "rerunnable", "public")); var pgColumns = GetPostgresColumns(_pgConnection, "rerunnable", "public"); Assert.Equal(3, pgColumns.Count); // id, active, count - NO EXTRA COLUMNS Assert.Contains("id", pgColumns); @@ -692,7 +692,7 @@ public void LqlDefaults_Idempotent_BothPlatforms() Assert.Contains("count", pgColumns); // SQLite: Verify table exists with correct columns - Assert.True(SqliteTableExists(_sqliteConnection, "rerunnable")); + Assert.True(SchemaVerifier.SqliteTableExists(_sqliteConnection, "rerunnable")); var sqliteColumns = GetSqliteColumns(_sqliteConnection, "rerunnable"); Assert.Equal(3, sqliteColumns.Count); // id, active, count - NO EXTRA COLUMNS Assert.Contains("id", sqliteColumns); @@ -1180,38 +1180,6 @@ private static string GetSqliteTableDdl(SqliteConnection conn, string tableName) return result?.ToString() ?? ""; } - /// - /// Check if a PostgreSQL table exists. - /// - private static bool PostgresTableExists( - NpgsqlConnection conn, - string tableName, - string schema = "public" - ) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = @schema AND table_name = @table - ) - """; - cmd.Parameters.AddWithValue("@schema", schema); - cmd.Parameters.AddWithValue("@table", tableName); - return (bool)cmd.ExecuteScalar()!; - } - - /// - /// Check if a SQLite table exists. - /// - private static bool SqliteTableExists(SqliteConnection conn, string tableName) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@name"; - cmd.Parameters.AddWithValue("@name", tableName); - return (long)cmd.ExecuteScalar()! > 0; - } - /// /// Get list of column names for a PostgreSQL table. /// diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationCliConsole.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationCliConsole.cs new file mode 100644 index 00000000..3d4b3957 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationCliConsole.cs @@ -0,0 +1,74 @@ +using System.Globalization; + +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [MIG-CLI-COMMANDS]: every in-process CLI invocation shares the +// process-wide Console writers, including invocations from different collections. +internal static class MigrationCliConsole +{ + private static readonly object Gate = new(); + + internal static (int ExitCode, string Output) Migrate( + string schemaPath, + string provider, + string output, + bool allowDestructive = false, + string? phase = null + ) => + Invoke(run: () => + DataProviderMigrate.Program.Main( + args: Arguments( + schemaPath: schemaPath, + provider: provider, + output: output, + allowDestructive: allowDestructive, + phase: phase + ) + ) + ); + + private static string[] Arguments( + string schemaPath, + string provider, + string output, + bool allowDestructive, + string? phase + ) => + [ + "migrate", + "--schema", + schemaPath, + "--provider", + provider, + "--output", + output, + .. (allowDestructive ? ["--allow-destructive"] : Array.Empty()), + .. (phase is null ? Array.Empty() : ["--phase", phase]), + ]; + + private static (int ExitCode, string Output) Invoke(Func run) + { + lock (Gate) + { + return Capture(run: run); + } + } + + private static (int ExitCode, string Output) Capture(Func run) + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var output = new StringWriter(formatProvider: CultureInfo.InvariantCulture); + Console.SetOut(newOut: output); + Console.SetError(newError: output); + try + { + return (run(), output.ToString()); + } + finally + { + Console.SetOut(newOut: originalOut); + Console.SetError(newError: originalError); + } + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformAdoNetEffectsTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformAdoNetEffectsTests.cs new file mode 100644 index 00000000..e4257173 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformAdoNetEffectsTests.cs @@ -0,0 +1,125 @@ +using System.Data.Common; +using Nimblesite.DataProvider.Core.CodeGeneration; +using Nimblesite.Sql.Model; +using ColumnMetadataError = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>; +using ColumnMetadataOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed partial record MigrationPlatformRegressionTests +{ + // Implements [CON-SHARED-CORE] and [MIG-TEST-CROSS-PLATFORM]. + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task AdoNetEffects_InspectsLiveQueriesAndReportsInvalidSql(string provider) + { + await WithAsyncTargetAsync(provider, AssertAdoNetEffectsAsync).ConfigureAwait(true); + } + + private static async Task AssertAdoNetEffectsAsync(MigrationTarget target) + { + var effects = CreateEffects(target.Provider); + var connectionString = + target.Provider == "sqlite" + ? new SqliteConnectionStringBuilder { DataSource = target.Output }.ConnectionString + : target.Output; + var first = await effects + .GetColumnMetadataFromSqlAsync( + connectionString, + "SELECT 1 AS first_value, 'x' AS second_value", + Array.Empty() + ) + .ConfigureAwait(false); + var columns = Assert.IsType(first).Value; + Assert.Equal(2, columns.Count); + Assert.Equal("first_value", columns[0].Name); + Assert.Equal("second_value", columns[1].Name); + Assert.NotEqual(columns[0].Name, columns[1].Name); + + var again = await effects + .GetColumnMetadataFromSqlAsync( + connectionString, + "SELECT 1 AS first_value, 'x' AS second_value", + Array.Empty() + ) + .ConfigureAwait(false); + Assert.Equal( + columns.Select(column => column.Name), + Assert.IsType(again).Value.Select(column => column.Name) + ); + + var parameterized = await effects + .GetColumnMetadataFromSqlAsync( + connectionString, + "SELECT @id AS selected_id, @limit AS selected_limit", + [new ParameterInfo("id"), new ParameterInfo("limit")] + ) + .ConfigureAwait(false); + var parameterColumns = Assert.IsType(parameterized).Value; + Assert.Equal(2, parameterColumns.Count); + Assert.Equal("selected_id", parameterColumns[0].Name); + Assert.Equal("selected_limit", parameterColumns[1].Name); + + var invalid = await effects + .GetColumnMetadataFromSqlAsync( + connectionString, + "SELECT * FROM metadata_table_that_does_not_exist", + Array.Empty() + ) + .ConfigureAwait(false); + var error = Assert.IsType(invalid); + Assert.False(string.IsNullOrWhiteSpace(error.Value.Message)); + Assert.Contains( + "metadata_table_that_does_not_exist", + error.Value.Message, + StringComparison.OrdinalIgnoreCase + ); + + var blankSql = await effects + .GetColumnMetadataFromSqlAsync(connectionString, " ", Array.Empty()) + .ConfigureAwait(false); + Assert.False( + string.IsNullOrWhiteSpace(Assert.IsType(blankSql).Value.Message) + ); + var blankConnection = await effects + .GetColumnMetadataFromSqlAsync("", "SELECT 1 AS id", Array.Empty()) + .ConfigureAwait(false); + Assert.False( + string.IsNullOrWhiteSpace( + Assert.IsType(blankConnection).Value.Message + ) + ); + } + + private static AdoNetDatabaseEffects CreateEffects(string provider) + { + Assert.True(provider is "sqlite" or "postgres" or "sqlserver"); + Func connectionFactory = provider switch + { + "sqlite" => connectionString => new SqliteConnection(connectionString), + "postgres" => connectionString => new NpgsqlConnection(connectionString), + "sqlserver" => connectionString => new Microsoft.Data.SqlClient.SqlConnection( + connectionString + ), + _ => connectionString => new SqliteConnection(connectionString), + }; + return new AdoNetDatabaseEffects( + connectionFactory, + (fieldType, _, isNullable) => isNullable ? $"{fieldType.Name}?" : fieldType.Name + ); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformFixture.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformFixture.cs new file mode 100644 index 00000000..fd12d10e --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformFixture.cs @@ -0,0 +1,54 @@ +using Microsoft.Data.SqlClient; +using Testcontainers.MsSql; + +namespace Nimblesite.DataProvider.Migration.Tests; + +[CollectionDefinition(Name)] +public sealed record MigrationPlatformSuite + : ICollectionFixture, + ICollectionFixture +{ + public const string Name = "Migration platforms"; +} + +public sealed record SqlServerContainerFixture : IAsyncLifetime +{ + private readonly Lazy> _container = new(StartContainerAsync); + private long _databaseCounter; + + // Implements [MIG-TEST-CROSS-PLATFORM]: other providers run before SQL Server starts. + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_container.IsValueCreated && _container.Value.IsCompletedSuccessfully) + { + var container = await _container.Value.ConfigureAwait(false); + await container.DisposeAsync().ConfigureAwait(false); + } + } + + public async Task CreateDatabaseConnectionStringAsync() + { + var container = await _container.Value.ConfigureAwait(false); + var name = $"migration_regression_{Interlocked.Increment(ref _databaseCounter)}"; + using var admin = new SqlConnection(container.GetConnectionString()); + await admin.OpenAsync().ConfigureAwait(false); + using var command = admin.CreateCommand(); + command.CommandText = $"CREATE DATABASE [{name}]"; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + var builder = new SqlConnectionStringBuilder(container.GetConnectionString()) + { + InitialCatalog = name, + Pooling = false, + }; + return builder.ConnectionString; + } + + private static async Task StartContainerAsync() + { + var container = new MsSqlBuilder().Build(); + await container.StartAsync().ConfigureAwait(false); + return container; + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Catalog.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Catalog.cs new file mode 100644 index 00000000..c5ac7c28 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Catalog.cs @@ -0,0 +1,218 @@ +using System.Collections.Immutable; +using System.Data.Common; +using System.Globalization; + +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed partial record MigrationPlatformRegressionTests +{ + // Implements [MIG-DIFF-FOREIGN-KEY] and [MIG-DIFF-DESTRUCTIVE]. + private static void AssertTableExists(MigrationTarget target, string table, bool expected) => + Assert.Equal(expected, CatalogObjectExists(target, table, column: null)); + + // Implements [MIG-DIFF-FOREIGN-KEY] and [MIG-DIFF-DESTRUCTIVE]. + private static void AssertColumnExists( + MigrationTarget target, + string table, + string column, + bool expected + ) => Assert.Equal(expected, CatalogObjectExists(target, table, column)); + + private static bool CatalogObjectExists(MigrationTarget target, string table, string? column) + { + using var command = target.Connection.CreateCommand(); + command.CommandText = (target.Provider, column) switch + { + ("sqlite", null) => + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @table", + ("sqlite", _) => "SELECT COUNT(*) FROM pragma_table_info(@table) WHERE name = @column", + (_, null) => + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = @schema AND table_name = @table", + _ => + "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @schema AND table_name = @table AND column_name = @column", + }; + if (target.Provider != "sqlite") + { + AddParameter(command, "@schema", target.Schema); + } + AddParameter(command, "@table", table); + if (column is not null) + { + AddParameter(command, "@column", column); + } + return Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture) == 1; + } + + private static bool ColumnIsNullable(MigrationTarget target, string table, string column) + { + using var command = target.Connection.CreateCommand(); + if (target.Provider == "sqlite") + { + command.CommandText = $"PRAGMA table_info([{table}])"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + if (reader.GetString(1) == column) + { + return reader.GetInt32(3) == 0; + } + } + Assert.Fail($"Missing {table}.{column} in {target.Provider}"); + return false; + } + command.CommandText = + "SELECT is_nullable FROM information_schema.columns WHERE table_schema = @schema AND table_name = @table AND column_name = @column"; + AddParameter(command, "@schema", target.Schema); + AddParameter(command, "@table", table); + AddParameter(command, "@column", column); + var value = command.ExecuteScalar(); + Assert.NotNull(value); + return string.Equals(value.ToString(), "YES", StringComparison.OrdinalIgnoreCase); + } + + private static ImmutableArray ReadForeignKeys( + MigrationTarget target, + string table + ) + { + using var command = target.Connection.CreateCommand(); + command.CommandText = target.Provider switch + { + "sqlite" => $"PRAGMA foreign_key_list([{table}])", + "postgres" => """ + SELECT kcu.column_name, ccu.table_name, ccu.column_name, rc.delete_rule + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema + JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema + WHERE tc.table_schema = @schema AND tc.table_name = @table AND tc.constraint_type = 'FOREIGN KEY' + """, + "sqlserver" => """ + SELECT source_column.name, referenced_table.name, referenced_column.name, + fk.delete_referential_action_desc, fk.is_disabled, fk.is_not_trusted, + referenced_schema.name + FROM sys.foreign_keys fk + JOIN sys.foreign_key_columns link ON link.constraint_object_id = fk.object_id + JOIN sys.tables source_table ON source_table.object_id = fk.parent_object_id + JOIN sys.schemas source_schema ON source_schema.schema_id = source_table.schema_id + JOIN sys.columns source_column ON source_column.object_id = source_table.object_id AND source_column.column_id = link.parent_column_id + JOIN sys.tables referenced_table ON referenced_table.object_id = fk.referenced_object_id + JOIN sys.schemas referenced_schema ON referenced_schema.schema_id = referenced_table.schema_id + JOIN sys.columns referenced_column ON referenced_column.object_id = referenced_table.object_id AND referenced_column.column_id = link.referenced_column_id + WHERE source_schema.name = @schema AND source_table.name = @table + """, + _ => string.Empty, + }; + if (target.Provider != "sqlite") + { + AddParameter(command, "@schema", target.Schema); + AddParameter(command, "@table", table); + } + using var reader = command.ExecuteReader(); + var foreignKeys = ImmutableArray.CreateBuilder(); + while (reader.Read()) + { + if (target.Provider == "sqlserver") + { + Assert.False(reader.GetBoolean(4), "Foreign key is disabled"); + Assert.False(reader.GetBoolean(5), "Foreign key is not trusted"); + Assert.Equal(target.Schema, reader.GetString(6)); + } + foreignKeys.Add( + target.Provider == "sqlite" + ? new ForeignKeySnapshot( + reader.GetString(3), + reader.GetString(2), + reader.GetString(4), + reader.GetString(6) + ) + : new ForeignKeySnapshot( + reader.GetString(0), + reader.GetString(1), + reader.GetString(2), + reader.GetString(3) + ) + ); + } + return foreignKeys.ToImmutable(); + } + + // Implements [MIG-DIFF-FOREIGN-KEY]. + private static void AssertEndUserForeignKey(MigrationTarget target) + { + var foreignKey = Assert.Single(ReadForeignKeys(target, "usage_events")); + Assert.Equal("end_user_id", foreignKey.Column); + Assert.Equal("tenant_end_users", foreignKey.ReferencedTable); + Assert.Equal("id", foreignKey.ReferencedColumn); + Assert.Equal("SET NULL", NormalizedDeleteAction(foreignKey.OnDelete)); + Assert.Empty(ReadForeignKeys(target, "tenant_end_users")); + } + + // Implements [MIG-DIFF-DESTRUCTIVE]. + private static void AssertCleanupForeignKeys( + MigrationTarget target, + bool includeTenantForeignKey, + bool includeProjectForeignKey + ) + { + var eventKeys = ReadForeignKeys(target, "usage_events"); + Assert.Equal( + (includeTenantForeignKey ? 1 : 0) + (includeProjectForeignKey ? 1 : 0), + eventKeys.Length + ); + if (includeTenantForeignKey) + { + Assert.Contains(eventKeys, fk => MatchesCleanupForeignKey(fk, "tenant_id", "tenants")); + } + else + { + Assert.DoesNotContain(eventKeys, fk => fk.Column == "tenant_id"); + } + if (includeProjectForeignKey) + { + Assert.Contains( + eventKeys, + fk => MatchesCleanupForeignKey(fk, "project_id", "projects") + ); + } + else + { + Assert.DoesNotContain(eventKeys, fk => fk.Column == "project_id"); + } + var auditKey = Assert.Single(ReadForeignKeys(target, "audit_events")); + Assert.True(MatchesCleanupForeignKey(auditKey, "tenant_id", "tenants")); + Assert.Empty(ReadForeignKeys(target, "tenants")); + Assert.Empty(ReadForeignKeys(target, "projects")); + } + + private static bool MatchesCleanupForeignKey( + ForeignKeySnapshot fk, + string column, + string table + ) => + fk.Column == column + && fk.ReferencedTable == table + && fk.ReferencedColumn == "id" + && NormalizedDeleteAction(fk.OnDelete) == "CASCADE"; + + private static string NormalizedDeleteAction(string action) => + action.Replace('_', ' ').ToUpperInvariant(); + + private static void AddParameter(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + private sealed record ForeignKeySnapshot( + string Column, + string ReferencedTable, + string ReferencedColumn, + string OnDelete + ); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Infrastructure.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Infrastructure.cs new file mode 100644 index 00000000..5da48df2 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Infrastructure.cs @@ -0,0 +1,144 @@ +using System.Data.Common; + +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed partial record MigrationPlatformRegressionTests +{ + private Task WithTargetAsync(string provider, Action run) => + WithAsyncTargetAsync( + provider, + target => + { + run(target); + return Task.CompletedTask; + } + ); + + private async Task WithAsyncTargetAsync(string provider, Func run) + { + if (provider == "postgres") + { + using var connection = await _postgres + .CreateDatabaseAsync("migration_regression") + .ConfigureAwait(false); + await run( + new MigrationTarget(provider, "public", connection.ConnectionString, connection) + ) + .ConfigureAwait(false); + return; + } + if (provider == "sqlserver") + { + var connectionString = await _sqlServer + .CreateDatabaseConnectionStringAsync() + .ConfigureAwait(false); + using var connection = new Microsoft.Data.SqlClient.SqlConnection(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + await run(new MigrationTarget(provider, "dbo", connectionString, connection)) + .ConfigureAwait(false); + return; + } + Assert.Equal("sqlite", provider); + var path = Path.Combine(Path.GetTempPath(), $"migration_regression_{Guid.NewGuid():N}.db"); + try + { + using var sqlite = new SqliteConnection( + new SqliteConnectionStringBuilder + { + DataSource = path, + ForeignKeys = true, + }.ConnectionString + ); + await sqlite.OpenAsync().ConfigureAwait(false); + using var pragma = sqlite.CreateCommand(); + pragma.CommandText = "PRAGMA foreign_keys"; + Assert.Equal( + 1L, + Assert.IsType(await pragma.ExecuteScalarAsync().ConfigureAwait(false)) + ); + await run(new MigrationTarget(provider, "main", path, sqlite)).ConfigureAwait(false); + } + finally + { + File.Delete(path); + } + } + + private static void Migrate( + MigrationTarget target, + SchemaDefinition desired, + bool allowDestructive = false + ) + { + var result = RunMigrate(target, desired, allowDestructive); + Assert.True(result.ExitCode == 0, $"{target.Provider} migration failed: {result.Output}"); + Assert.DoesNotContain( + "SCHEMA INTEGRITY CHECK FAILED", + result.Output, + StringComparison.Ordinal + ); + Assert.DoesNotContain( + "MIG-E-RLS-MSSQL-UNSUPPORTED", + result.Output, + StringComparison.Ordinal + ); + } + + private static (int ExitCode, string Output) RunMigrate( + MigrationTarget target, + SchemaDefinition desired, + bool allowDestructive + ) + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText( + path, + SchemaYamlSerializer.ToYaml(WithSchema(desired, target.Schema)) + ); + return RunMigrate(target, path, allowDestructive); + } + finally + { + File.Delete(path); + } + } + + private static (int ExitCode, string Output) RunMigrate( + MigrationTarget target, + string path, + bool allowDestructive + ) + { + return MigrationCliConsole.Migrate( + schemaPath: path, + provider: target.Provider, + output: target.Output, + allowDestructive: allowDestructive + ); + } + + private static SchemaDefinition WithSchema(SchemaDefinition definition, string schema) => + definition with + { + Tables = definition + .Tables.Select(table => + table with + { + Schema = schema, + ForeignKeys = table + .ForeignKeys.Select(fk => fk with { ReferencedSchema = schema }) + .ToArray(), + } + ) + .ToArray(), + }; + + private sealed record MigrationTarget( + string Provider, + string Schema, + string Output, + DbConnection Connection + ); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Policy.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Policy.cs new file mode 100644 index 00000000..29e92afe --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Policy.cs @@ -0,0 +1,229 @@ +using System.Collections.Immutable; + +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed partial record MigrationPlatformRegressionTests +{ + private static void AssertUnsupportedPolicyMigration( + MigrationTarget target, + bool usingAllowed, + bool checkAllowed + ) + { + var original = PolicySchema(usingAllowed: true, checkAllowed: true); + Migrate( + target, + original with + { + Tables = original + .Tables.Select(table => table with { RowLevelSecurity = null }) + .ToArray(), + } + ); + var before = SqlServerSchemaSnapshot(target); + AssertUnsupportedPolicy(target, original, before); + AssertUnsupportedPolicy(target, PolicySchema(usingAllowed, checkAllowed), before); + AssertUnsupportedPolicy(target, original, before); + } + + private static void AssertUnsupportedPolicy( + MigrationTarget target, + SchemaDefinition desired, + string before + ) + { + var result = RunMigrate(target, desired, allowDestructive: true); + Assert.Equal(1, result.ExitCode); + Assert.Contains("MIG-E-RLS-MSSQL-UNSUPPORTED", result.Output, StringComparison.Ordinal); + Assert.Equal(before, SqlServerSchemaSnapshot(target)); + using var command = target.Connection.CreateCommand(); + command.CommandText = + "SELECT (SELECT COUNT(*) FROM sys.security_policies) + (SELECT COUNT(*) FROM sys.objects WHERE type = 'IF')"; + Assert.Equal( + 0, + Convert.ToInt32( + command.ExecuteScalar(), + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } + + private static string SqlServerSchemaSnapshot(MigrationTarget target) => + SchemaYamlSerializer.ToYaml( + Assert + .IsType( + Nimblesite.DataProvider.Migration.SqlServer.SqlServerSchemaInspector.Inspect( + connection: Assert.IsType( + target.Connection + ) + ) + ) + .Value + ); + + private static PolicySnapshot ReadPolicy(MigrationTarget target) => + target.Provider switch + { + "postgres" => ReadPostgresPolicy(target), + "sqlite" => ReadSqlitePolicy(target), + "sqlserver" => ReadSqlServerPolicy(target), + _ => UnsupportedPolicyProvider(target.Provider), + }; + + private static PolicySnapshot UnsupportedPolicyProvider(string provider) + { + Assert.Fail($"Unsupported policy provider: {provider}"); + return new PolicySnapshot(string.Empty, string.Empty); + } + + private static PolicySnapshot ReadPostgresPolicy(MigrationTarget target) + { + using var command = target.Connection.CreateCommand(); + command.CommandText = """ + SELECT policy.qual, policy.with_check, table_catalog.relrowsecurity, policy.cmd + FROM pg_policies policy + JOIN pg_namespace table_schema ON table_schema.nspname = policy.schemaname + JOIN pg_class table_catalog ON table_catalog.relnamespace = table_schema.oid + AND table_catalog.relname = policy.tablename + WHERE policy.schemaname = @schema AND policy.tablename = 'documents' + AND policy.policyname = 'documents_access' + """; + AddParameter(command, "@schema", target.Schema); + using var reader = command.ExecuteReader(); + Assert.True(reader.Read(), "Missing documents_access policy in pg_policies"); + Assert.True(reader.GetBoolean(2), "Row-level security is disabled on documents"); + Assert.Equal("ALL", reader.GetString(3)); + var snapshot = new PolicySnapshot(reader.GetString(0), reader.GetString(1)); + Assert.False(reader.Read(), "Duplicate documents_access policy"); + return snapshot; + } + + private static PolicySnapshot ReadSqlitePolicy(MigrationTarget target) => + new( + ReadSqliteObjectSql(target, "view", "name = 'documents_secure'"), + ReadSqliteObjectSql( + target, + "trigger", + "tbl_name = 'documents' AND name LIKE '%insert%'" + ) + ); + + private static string ReadSqliteObjectSql(MigrationTarget target, string type, string condition) + { + using var command = target.Connection.CreateCommand(); + command.CommandText = + $"SELECT sql FROM sqlite_master WHERE type = '{type}' AND {condition} ORDER BY name"; + using var reader = command.ExecuteReader(); + var definitions = ImmutableArray.CreateBuilder(); + while (reader.Read()) + { + definitions.Add(reader.GetString(0)); + } + return Assert.Single(definitions); + } + + private static PolicySnapshot ReadSqlServerPolicy(MigrationTarget target) + { + using var command = target.Connection.CreateCommand(); + command.CommandText = """ + SELECT predicate.predicate_type_desc, predicate.predicate_definition, + module.definition, policy.is_enabled + FROM sys.security_policies policy + JOIN sys.schemas policy_schema ON policy_schema.schema_id = policy.schema_id + JOIN sys.security_predicates predicate ON predicate.object_id = policy.object_id + JOIN sys.tables target_table ON target_table.object_id = predicate.target_object_id + JOIN sys.schemas target_schema ON target_schema.schema_id = target_table.schema_id + CROSS JOIN sys.sql_modules module + JOIN sys.objects function_object ON function_object.object_id = module.object_id + JOIN sys.schemas function_schema ON function_schema.schema_id = function_object.schema_id + WHERE policy_schema.name = @schema AND target_schema.name = @schema + AND target_table.name = 'documents' AND policy.name = 'documents_access' + AND ( + LEFT(predicate.predicate_definition, + LEN(QUOTENAME(function_schema.name) + '.' + QUOTENAME(function_object.name) + '(')) + = QUOTENAME(function_schema.name) + '.' + QUOTENAME(function_object.name) + '(' + OR LEFT(predicate.predicate_definition, + LEN(function_schema.name + '.' + function_object.name + '(')) + = function_schema.name + '.' + function_object.name + '(' + ) + ORDER BY predicate.predicate_type_desc, predicate.predicate_definition + """; + AddParameter(command, "@schema", target.Schema); + using var reader = command.ExecuteReader(); + var filters = ImmutableArray.CreateBuilder(); + var blocks = ImmutableArray.CreateBuilder(); + while (reader.Read()) + { + Assert.True(reader.GetBoolean(3), "Security policy is disabled"); + var definition = $"{reader.GetString(1)}\n{reader.GetString(2)}"; + if (reader.GetString(0) == "FILTER") + { + filters.Add(definition); + } + else if (reader.GetString(0) == "BLOCK") + { + blocks.Add(definition); + } + } + Assert.NotEmpty(filters); + Assert.NotEmpty(blocks); + return new PolicySnapshot(string.Join("\n", filters), string.Join("\n", blocks)); + } + + private static void AssertChangedClause( + string provider, + PolicySnapshot original, + PolicySnapshot changed, + bool usingAllowed, + bool checkAllowed + ) + { + if (usingAllowed) + { + Assert.Equal(original.Using, changed.Using); + } + else + { + Assert.NotEqual(original.Using, changed.Using); + } + if (checkAllowed) + { + Assert.Equal(original.WithCheck, changed.WithCheck); + } + else + { + Assert.NotEqual(original.WithCheck, changed.WithCheck); + } + Assert.False(string.IsNullOrWhiteSpace(changed.Using), provider); + Assert.False(string.IsNullOrWhiteSpace(changed.WithCheck), provider); + } + + // Implements [RLS-DIFF]. Assert the requested clauses, including the one not changed. + private static void AssertPolicyMatches( + string provider, + PolicySnapshot snapshot, + bool usingAllowed, + bool checkAllowed + ) + { + AssertPredicateMatches(provider, "USING", snapshot.Using, usingAllowed); + AssertPredicateMatches(provider, "WITH CHECK", snapshot.WithCheck, checkAllowed); + } + + private static void AssertPredicateMatches( + string provider, + string clause, + string definition, + bool allowed + ) + { + var expected = allowed ? "IS NOT NULL" : "IS NULL"; + var forbidden = allowed ? "IS NULL" : "IS NOT NULL"; + Assert.Contains(expected, definition, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(forbidden, definition, StringComparison.OrdinalIgnoreCase); + Assert.Contains("id", definition, StringComparison.OrdinalIgnoreCase); + Assert.False(string.IsNullOrWhiteSpace(definition), $"{provider} {clause} is missing"); + } + + private sealed record PolicySnapshot(string Using, string WithCheck); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Schemas.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Schemas.cs new file mode 100644 index 00000000..b2e8d10c --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.Schemas.cs @@ -0,0 +1,203 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed partial record MigrationPlatformRegressionTests +{ + private static SchemaDefinition NullableSchema( + bool stripeIdNullable, + bool externalIdNullable + ) => + new() + { + Name = "issue109", + Tables = + [ + ParentTable("tenants"), + new TableDefinition + { + Name = "topup", + Columns = + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + new ColumnDefinition + { + Name = "stripe_payment_intent_id", + Type = PortableTypes.Text, + IsNullable = stripeIdNullable, + }, + new ColumnDefinition + { + Name = "required_receipt_reference", + Type = PortableTypes.Text, + IsNullable = false, + }, + new ColumnDefinition + { + Name = "external_id", + Type = PortableTypes.Uuid, + IsNullable = externalIdNullable, + }, + new ColumnDefinition + { + Name = "optional_note", + Type = PortableTypes.Text, + IsNullable = true, + }, + UuidColumn("tenant_id"), + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + ForeignKeys = [ForeignKey("tenant_id", "tenants")], + }, + ], + }; + + private static SchemaDefinition ForeignKeySchema(bool parent, bool column, bool fk) + { + var endUser = new TableDefinition + { + Name = "tenant_end_users", + Columns = + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + }; + var events = new TableDefinition + { + Name = "usage_events", + Columns = column + ? + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + new ColumnDefinition { Name = "end_user_id", Type = PortableTypes.Uuid }, + ] + : + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + ForeignKeys = fk ? [EndUserForeignKey()] : [], + }; + return new SchemaDefinition + { + Name = "issues79_80", + Tables = parent ? [endUser, events] : [events], + }; + } + + private static ForeignKeyDefinition EndUserForeignKey() => + new() + { + Columns = ["end_user_id"], + ReferencedTable = "tenant_end_users", + ReferencedColumns = ["id"], + OnDelete = ForeignKeyAction.SetNull, + }; + + private static SchemaDefinition ForeignKeyCleanupSchema( + bool includeTenantForeignKey, + bool includeProjectForeignKey, + bool includeObsoleteTable + ) + { + var tenants = ParentTable("tenants"); + var projects = ParentTable("projects"); + var events = new TableDefinition + { + Name = "usage_events", + Columns = [UuidColumn("id"), UuidColumn("tenant_id"), UuidColumn("project_id")], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + ForeignKeys = + includeTenantForeignKey && includeProjectForeignKey + ? [ForeignKey("tenant_id", "tenants"), ForeignKey("project_id", "projects")] + : includeTenantForeignKey ? [ForeignKey("tenant_id", "tenants")] + : [ForeignKey("project_id", "projects")], + }; + var audit = new TableDefinition + { + Name = "audit_events", + Columns = [UuidColumn("id"), UuidColumn("tenant_id")], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + ForeignKeys = [ForeignKey("tenant_id", "tenants")], + }; + return new SchemaDefinition + { + Name = "issue105", + Tables = includeObsoleteTable + ? [tenants, projects, events, audit, ParentTable("obsolete_records")] + : [tenants, projects, events, audit], + }; + } + + private static TableDefinition ParentTable(string name) => + new() + { + Name = name, + Columns = [UuidColumn("id")], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + }; + + private static ColumnDefinition UuidColumn(string name) => + new() + { + Name = name, + Type = PortableTypes.Uuid, + IsNullable = false, + }; + + private static ForeignKeyDefinition ForeignKey(string column, string referencedTable) => + new() + { + Columns = [column], + ReferencedTable = referencedTable, + ReferencedColumns = ["id"], + OnDelete = ForeignKeyAction.Cascade, + }; + + private static SchemaDefinition PolicySchema(bool usingAllowed, bool checkAllowed) => + new() + { + Name = "issue98", + Tables = + [ + new TableDefinition + { + Name = "documents", + Columns = [UuidColumn("id")], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + RowLevelSecurity = new RlsPolicySetDefinition + { + Policies = + [ + new RlsPolicyDefinition + { + Name = "documents_access", + Operations = [RlsOperation.All], + UsingLql = usingAllowed ? "id is not null" : "id is null", + WithCheckLql = checkAllowed ? "id is not null" : "id is null", + }, + ], + }, + }, + ], + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.cs new file mode 100644 index 00000000..79faa969 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformRegressionTests.cs @@ -0,0 +1,377 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +/// One end-to-end regression suite for SQLite, PostgreSQL, and SQL Server. +[Collection(MigrationPlatformSuite.Name)] +public sealed partial record MigrationPlatformRegressionTests +{ + private readonly MigrationPostgresContainerFixture _postgres; + private readonly SqlServerContainerFixture _sqlServer; + + public MigrationPlatformRegressionTests( + MigrationPostgresContainerFixture postgres, + SqlServerContainerFixture sqlServer + ) + { + _postgres = postgres; + _sqlServer = sqlServer; + } + + // Implements [MIG-DIFF-ADDITIVE]. Regression: #109. + [Theory] + [InlineData("sqlite", true, false)] + [InlineData("postgres", true, false)] + [InlineData("sqlserver", true, false)] + [InlineData("sqlite", false, true)] + [InlineData("postgres", false, true)] + [InlineData("sqlserver", false, true)] + [InlineData("sqlite", true, true)] + [InlineData("postgres", true, true)] + [InlineData("sqlserver", true, true)] + public async Task AdditiveMigration_RelaxesNotNullWithoutDroppingOtherConstraints( + string provider, + bool stripeIdNullable, + bool externalIdNullable + ) + { + await WithTargetAsync( + provider, + target => AssertNullableMigration(target, stripeIdNullable, externalIdNullable) + ) + .ConfigureAwait(true); + } + + private static void AssertNullableMigration( + MigrationTarget target, + bool stripeIdNullable, + bool externalIdNullable + ) + { + var original = NullableSchema(stripeIdNullable: false, externalIdNullable: false); + Migrate(target, original); + AssertNullableColumns(target, stripeIdNullable: false, externalIdNullable: false); + + var desired = NullableSchema(stripeIdNullable, externalIdNullable); + Migrate(target, desired); + AssertNullableColumns(target, stripeIdNullable, externalIdNullable); + Migrate(target, desired); + AssertNullableColumns(target, stripeIdNullable, externalIdNullable); + } + + private static void AssertNullableColumns( + MigrationTarget target, + bool stripeIdNullable, + bool externalIdNullable + ) + { + AssertTableExists(target, "topup", expected: true); + Assert.Equal( + stripeIdNullable, + ColumnIsNullable(target, "topup", "stripe_payment_intent_id") + ); + Assert.Equal(externalIdNullable, ColumnIsNullable(target, "topup", "external_id")); + Assert.False(ColumnIsNullable(target, "topup", "required_receipt_reference")); + Assert.True(ColumnIsNullable(target, "topup", "optional_note")); + Assert.False(ColumnIsNullable(target, "topup", "id")); + Assert.False(ColumnIsNullable(target, "topup", "tenant_id")); + AssertTableExists(target, "tenants", expected: true); + Assert.False(ColumnIsNullable(target, "tenants", "id")); + var foreignKey = Assert.Single(ReadForeignKeys(target, "topup")); + Assert.Equal("tenant_id", foreignKey.Column); + Assert.Equal("tenants", foreignKey.ReferencedTable); + Assert.Equal("id", foreignKey.ReferencedColumn); + Assert.Equal("CASCADE", NormalizedDeleteAction(foreignKey.OnDelete)); + } + + // Implements [MIG-DIFF-FOREIGN-KEY]. Regression: #79 and #80. + [Theory] + [InlineData("sqlite", false, false)] + [InlineData("postgres", false, false)] + [InlineData("sqlserver", false, false)] + [InlineData("sqlite", true, false)] + [InlineData("postgres", true, false)] + [InlineData("sqlserver", true, false)] + [InlineData("sqlite", false, true)] + [InlineData("postgres", false, true)] + [InlineData("sqlserver", false, true)] + public async Task AdditiveMigration_AddsForeignKeyWhenParentOrColumnIsNew( + string provider, + bool parentInitiallyExists, + bool columnInitiallyExists + ) + { + await WithTargetAsync( + provider, + target => + AssertNewColumnForeignKeyMigration( + target, + parentInitiallyExists, + columnInitiallyExists + ) + ) + .ConfigureAwait(true); + } + + private static void AssertNewColumnForeignKeyMigration( + MigrationTarget target, + bool parentInitiallyExists, + bool columnInitiallyExists + ) + { + Migrate( + target, + ForeignKeySchema( + parent: parentInitiallyExists, + column: columnInitiallyExists, + fk: false + ) + ); + AssertTableExists(target, "usage_events", expected: true); + AssertTableExists(target, "tenant_end_users", expected: parentInitiallyExists); + AssertColumnExists(target, "usage_events", "end_user_id", expected: columnInitiallyExists); + Assert.Empty(ReadForeignKeys(target, "usage_events")); + + var desired = ForeignKeySchema(parent: true, column: true, fk: true); + Migrate(target, desired); + AssertEndUserForeignKeyShape(target); + Migrate(target, desired); + AssertEndUserForeignKeyShape(target); + } + + // Implements [MIG-DIFF-FOREIGN-KEY]. Regression: #79 and #80, including a rerun after the column was already added. + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task AdditiveMigration_RepairsPartiallyMigratedForeignKey(string provider) + { + await WithTargetAsync(provider, AssertPartialForeignKeyMigration).ConfigureAwait(true); + } + + private static void AssertPartialForeignKeyMigration(MigrationTarget target) + { + Migrate(target, ForeignKeySchema(parent: true, column: true, fk: false)); + AssertTableExists(target, "tenant_end_users", expected: true); + AssertColumnExists(target, "usage_events", "end_user_id", expected: true); + Assert.True(ColumnIsNullable(target, "usage_events", "end_user_id")); + Assert.Empty(ReadForeignKeys(target, "usage_events")); + + var desired = ForeignKeySchema(parent: true, column: true, fk: true); + Migrate(target, desired); + AssertEndUserForeignKeyShape(target); + Migrate(target, desired); + AssertEndUserForeignKeyShape(target); + } + + private static void AssertEndUserForeignKeyShape(MigrationTarget target) + { + AssertTableExists(target, "usage_events", expected: true); + AssertTableExists(target, "tenant_end_users", expected: true); + AssertColumnExists(target, "usage_events", "end_user_id", expected: true); + Assert.True(ColumnIsNullable(target, "usage_events", "end_user_id")); + Assert.False(ColumnIsNullable(target, "usage_events", "id")); + Assert.False(ColumnIsNullable(target, "tenant_end_users", "id")); + AssertEndUserForeignKey(target); + } + + // Implements [MIG-DIFF-DESTRUCTIVE]. Regression: #105. Either obsolete FK may be removed. + [Theory] + [InlineData("sqlite", true, false)] + [InlineData("postgres", true, false)] + [InlineData("sqlserver", true, false)] + [InlineData("sqlite", false, true)] + [InlineData("postgres", false, true)] + [InlineData("sqlserver", false, true)] + public async Task DestructiveMigration_KeepsDeclaredForeignKeysAndRemovesOnlyObsoleteOne( + string provider, + bool keepTenantForeignKey, + bool keepProjectForeignKey + ) + { + await WithTargetAsync( + provider, + target => + AssertDestructiveForeignKeyMigration( + target, + keepTenantForeignKey, + keepProjectForeignKey + ) + ) + .ConfigureAwait(true); + } + + private static void AssertDestructiveForeignKeyMigration( + MigrationTarget target, + bool keepTenantForeignKey, + bool keepProjectForeignKey + ) + { + var original = ForeignKeyCleanupSchema( + includeTenantForeignKey: true, + includeProjectForeignKey: true, + includeObsoleteTable: true + ); + AssertRestoredCleanupSchema(target, original); + AssertUndisturbedCleanupSchema(target, original); + + var desired = ForeignKeyCleanupSchema( + includeTenantForeignKey: keepTenantForeignKey, + includeProjectForeignKey: keepProjectForeignKey, + includeObsoleteTable: false + ); + AssertRemovedCleanupSchema(target, desired, keepTenantForeignKey, keepProjectForeignKey); + AssertRemovedCleanupSchema(target, desired, keepTenantForeignKey, keepProjectForeignKey); + AssertRestoredCleanupSchema(target, original); + AssertRestoredCleanupSchema(target, original); + } + + private static void AssertRemovedCleanupSchema( + MigrationTarget target, + SchemaDefinition desired, + bool keepTenantForeignKey, + bool keepProjectForeignKey + ) => + MigrateAndAssertCleanup( + target, + desired, + includeTenantForeignKey: keepTenantForeignKey, + includeProjectForeignKey: keepProjectForeignKey, + includeObsoleteTable: false, + allowDestructive: true + ); + + private static void AssertRestoredCleanupSchema( + MigrationTarget target, + SchemaDefinition original + ) => + MigrateAndAssertCleanup( + target, + original, + includeTenantForeignKey: true, + includeProjectForeignKey: true, + includeObsoleteTable: true, + allowDestructive: false + ); + + private static void AssertUndisturbedCleanupSchema( + MigrationTarget target, + SchemaDefinition original + ) => + MigrateAndAssertCleanup( + target, + original, + includeTenantForeignKey: true, + includeProjectForeignKey: true, + includeObsoleteTable: true, + allowDestructive: true + ); + + private static void MigrateAndAssertCleanup( + MigrationTarget target, + SchemaDefinition schema, + bool includeTenantForeignKey, + bool includeProjectForeignKey, + bool includeObsoleteTable, + bool allowDestructive + ) + { + Migrate(target, schema, allowDestructive); + AssertCleanupSchema( + target, + includeTenantForeignKey: includeTenantForeignKey, + includeProjectForeignKey: includeProjectForeignKey + ); + AssertTableExists(target, "obsolete_records", expected: includeObsoleteTable); + } + + private static void AssertCleanupSchema( + MigrationTarget target, + bool includeTenantForeignKey, + bool includeProjectForeignKey + ) + { + AssertCleanupForeignKeys(target, includeTenantForeignKey, includeProjectForeignKey); + AssertTableExists(target, "tenants", expected: true); + AssertTableExists(target, "projects", expected: true); + AssertTableExists(target, "usage_events", expected: true); + AssertTableExists(target, "audit_events", expected: true); + AssertColumnExists(target, "usage_events", "project_id", expected: true); + Assert.False(ColumnIsNullable(target, "usage_events", "project_id")); + Assert.False(ColumnIsNullable(target, "usage_events", "tenant_id")); + Assert.False(ColumnIsNullable(target, "audit_events", "tenant_id")); + } + + // Implements [RLS-DIFF] and [RLS-MSSQL]. Regression: #98. Supported providers + // reconcile each predicate; unsupported SQL Server RLS must fail closed. + [Theory] + [InlineData("sqlite", false, true)] + [InlineData("postgres", false, true)] + [InlineData("sqlserver", false, true)] + [InlineData("sqlite", true, false)] + [InlineData("postgres", true, false)] + [InlineData("sqlserver", true, false)] + [InlineData("sqlite", false, false)] + [InlineData("postgres", false, false)] + [InlineData("sqlserver", false, false)] + public async Task RlsMigration_ReconcilesSupportedPredicatesAndRejectsUnsupportedProvider( + string provider, + bool usingAllowed, + bool checkAllowed + ) + { + await WithTargetAsync( + provider, + target => + { + if (provider == "sqlserver") + { + AssertUnsupportedPolicyMigration(target, usingAllowed, checkAllowed); + } + else + { + AssertPolicyMigration(target, usingAllowed, checkAllowed); + } + } + ) + .ConfigureAwait(true); + } + + private static void AssertPolicyMigration( + MigrationTarget target, + bool usingAllowed, + bool checkAllowed + ) + { + var originalSchema = PolicySchema(usingAllowed: true, checkAllowed: true); + Migrate(target, originalSchema); + var original = ReadPolicy(target); + Assert.False(string.IsNullOrWhiteSpace(original.Using)); + Assert.False(string.IsNullOrWhiteSpace(original.WithCheck)); + AssertPolicyMatches(target.Provider, original, usingAllowed: true, checkAllowed: true); + + var desired = PolicySchema(usingAllowed, checkAllowed); + Migrate(target, desired, allowDestructive: true); + var changed = ReadPolicy(target); + AssertChangedClause(target.Provider, original, changed, usingAllowed, checkAllowed); + AssertPolicyMatches(target.Provider, changed, usingAllowed, checkAllowed); + Migrate(target, desired, allowDestructive: true); + Assert.Equal(changed, ReadPolicy(target)); + AssertRestoredPolicy(target, originalSchema, changed, usingAllowed, checkAllowed); + } + + private static void AssertRestoredPolicy( + MigrationTarget target, + SchemaDefinition originalSchema, + PolicySnapshot changed, + bool usingAllowed, + bool checkAllowed + ) + { + Migrate(target, originalSchema, allowDestructive: true); + var restored = ReadPolicy(target); + AssertPolicyMatches(target.Provider, restored, usingAllowed: true, checkAllowed: true); + Assert.Equal(usingAllowed, changed.Using == restored.Using); + Assert.Equal(checkAllowed, changed.WithCheck == restored.WithCheck); + Migrate(target, originalSchema, allowDestructive: true); + Assert.Equal(restored, ReadPolicy(target)); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformTransactionTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformTransactionTests.cs new file mode 100644 index 00000000..8173ef1b --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPlatformTransactionTests.cs @@ -0,0 +1,90 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using Nimblesite.DataProvider.Core; + +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed partial record MigrationPlatformRegressionTests +{ + // Implements [MIG-TEST-CROSS-PLATFORM]: one transaction contract for every provider. + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task Transact_ReturnsValuePropagatesFailureAndReleasesTransaction(string provider) + { + await WithAsyncTargetAsync(provider, AssertTransactionContractAsync).ConfigureAwait(true); + } + + private static async Task AssertTransactionContractAsync(MigrationTarget target) + { + Assert.Equal(ConnectionState.Open, target.Connection.State); + var callbacks = 0; + var committed = await target + .Connection.Transact(async transaction => + { + callbacks++; + await AssertTransactionQueryAsync(target, transaction).ConfigureAwait(false); + return 42; + }) + .ConfigureAwait(false); + Assert.Equal(42, committed); + Assert.Equal(1, callbacks); + Assert.Equal(ConnectionState.Open, target.Connection.State); + AssertCanStartAnotherTransaction(target); + + var failure = await Assert + .ThrowsAsync(() => + target.Connection.Transact(async transaction => + { + callbacks++; + await AssertTransactionQueryAsync(target, transaction).ConfigureAwait(false); + throw new InvalidOperationException("transaction callback failed"); + }) + ) + .ConfigureAwait(true); + Assert.Equal("transaction callback failed", failure.Message); + Assert.Equal(2, callbacks); + Assert.Equal(ConnectionState.Open, target.Connection.State); + AssertCanStartAnotherTransaction(target); + + var recovered = await target + .Connection.Transact(async transaction => + { + callbacks++; + await AssertTransactionQueryAsync(target, transaction).ConfigureAwait(false); + return 7; + }) + .ConfigureAwait(false); + Assert.Equal(7, recovered); + Assert.Equal(3, callbacks); + AssertCanStartAnotherTransaction(target); + } + + private static async Task AssertTransactionQueryAsync( + MigrationTarget target, + IDbTransaction transaction + ) + { + if (transaction is not DbTransaction dbTransaction) + { + Assert.Fail($"{target.Provider} returned a transaction without DbTransaction support"); + return; + } + Assert.Same(target.Connection, dbTransaction.Connection); + using var command = target.Connection.CreateCommand(); + command.Transaction = dbTransaction; + command.CommandText = "SELECT 1"; + var value = await command.ExecuteScalarAsync().ConfigureAwait(false); + Assert.Equal(1, Convert.ToInt32(value, CultureInfo.InvariantCulture)); + } + + private static void AssertCanStartAnotherTransaction(MigrationTarget target) + { + using var transaction = target.Connection.BeginTransaction(); + Assert.Same(target.Connection, transaction.Connection); + transaction.Rollback(); + Assert.Equal(ConnectionState.Open, target.Connection.State); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPostgresContainerFixture.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPostgresContainerFixture.cs new file mode 100644 index 00000000..68d8ff0f --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationPostgresContainerFixture.cs @@ -0,0 +1,31 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed record MigrationPostgresContainerFixture : IAsyncLifetime +{ + private readonly Lazy> _fixture = new(StartFixtureAsync); + + // Implements [MIG-TEST-CROSS-PLATFORM]: a Postgres outage cannot block other providers. + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_fixture.IsValueCreated && _fixture.Value.IsCompletedSuccessfully) + { + var fixture = await _fixture.Value.ConfigureAwait(false); + await fixture.DisposeAsync().ConfigureAwait(false); + } + } + + public async Task CreateDatabaseAsync(string namePrefix) + { + var fixture = await _fixture.Value.ConfigureAwait(false); + return await fixture.CreateDatabaseAsync(namePrefix).ConfigureAwait(false); + } + + private static async Task StartFixtureAsync() + { + var fixture = new PostgresContainerFixture(); + await fixture.InitializeAsync().ConfigureAwait(false); + return fixture; + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationRunnerHardFailTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationRunnerHardFailTests.cs index 69ff54e7..17381dc4 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationRunnerHardFailTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrationRunnerHardFailTests.cs @@ -1,93 +1,115 @@ +using System.Collections.Immutable; +using MigrationRunnerResult = Outcome.Result< + bool, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; + namespace Nimblesite.DataProvider.Migration.Tests; -/// -/// Implements [MIG-RUNNER-HARD-FAIL]: when any operation fails, MigrationRunner.Apply -/// must return an Error result, even when ContinueOnError=true. The purpose of -/// ContinueOnError is to keep applying remaining operations after a failure for -/// diagnostic visibility; it must NEVER cause the runner to claim success when -/// operations were missed. Closes the spirit of issues #53 and #55: the migrator -/// must hard-fail when schema migrations are missed. -/// -public sealed class MigrationRunnerHardFailTests +public sealed partial record MigrationPlatformRegressionTests { - [Fact] - public void Apply_WithContinueOnError_FailedOperationCausesErrorResult() + // Implements [MIG-RUNNER-HARD-FAIL] and [MIG-TEST-CROSS-PLATFORM]. + [Theory] + [InlineData("sqlite", false)] + [InlineData("postgres", false)] + [InlineData("sqlserver", false)] + [InlineData("sqlite", true)] + [InlineData("postgres", true)] + [InlineData("sqlserver", true)] + public async Task Runner_FailedOperationReturnsErrorAndHonorsContinueOnError( + string provider, + bool continueOnError + ) { - var dbPath = Path.Combine(Path.GetTempPath(), $"hardfail_{Guid.NewGuid()}.db"); - using var connection = new SqliteConnection($"Data Source={dbPath}"); - connection.Open(); + await WithTargetAsync(provider, target => AssertRunnerHardFail(target, continueOnError)) + .ConfigureAwait(true); + } - try + private static void AssertRunnerHardFail(MigrationTarget target, bool continueOnError) + { + var baseline = new SchemaDefinition { - var goodOp = new CreateTableOperation( - new TableDefinition - { - Schema = "main", - Name = "good_table", - Columns = - [ - new ColumnDefinition - { - Name = "id", - Type = PortableTypes.BigInt, - IsNullable = false, - }, - ], - PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, - } - ); - - var brokenOp = new CreateTableOperation( - new TableDefinition - { - Schema = "main", - Name = "broken_table", - Columns = - [ - new ColumnDefinition - { - Name = "id", - Type = PortableTypes.BigInt, - IsNullable = false, - }, - ], - PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, - } - ); - - string GenerateDdl(SchemaOperation op) => - op == brokenOp - ? "INSERT INTO no_such_table (col) VALUES ('forced failure')" - : SqliteDdlGenerator.Generate(op); - - var result = MigrationRunner.Apply( - connection: connection, - operations: [goodOp, brokenOp], - generateDdl: GenerateDdl, - options: new MigrationOptions { ContinueOnError = true, UseTransaction = false } - ); + Name = "runner_hard_fail", + Tables = [ParentTable("runner_baseline")], + }; + Migrate(target, baseline); + AssertBaseline(target); + var before = new CreateTableOperation(ParentTable("runner_before")); + var broken = new CreateTableOperation(ParentTable("runner_broken")); + var after = new CreateTableOperation(ParentTable("runner_after")); + var generated = ImmutableArray.CreateBuilder(); + var result = ApplyFailureScenario( + target, + continueOnError, + before, + broken, + after, + generated + ); + AssertRunnerFailure(result, continueOnError, before, broken, after, generated); + AssertBaseline(target); + AssertTableExists(target, "runner_before", expected: false); + AssertTableExists(target, "runner_broken", expected: false); + AssertTableExists(target, "runner_after", expected: false); + Migrate(target, baseline); + AssertBaseline(target); + } - Assert.True( - condition: result is MigrationApplyResultError, - userMessage: "Apply must return Error when any operation fails, even with " - + "ContinueOnError=true. The runner reported success while a migration " - + "was missed." - ); - } - finally + private static MigrationRunnerResult ApplyFailureScenario( + MigrationTarget target, + bool continueOnError, + CreateTableOperation before, + CreateTableOperation broken, + CreateTableOperation after, + ImmutableArray.Builder generated + ) + { + string GenerateDdl(SchemaOperation operation) { - connection.Close(); - if (File.Exists(dbPath)) + generated.Add(operation); + return operation == broken ? "SELECT * FROM missing_runner_table" : "SELECT 1"; + } + return MigrationRunner.Apply( + connection: target.Connection, + operations: [before, broken, after], + generateDdl: GenerateDdl, + options: new MigrationOptions { - try - { - File.Delete(dbPath); - } - catch - { - /* file may be locked */ - } + ContinueOnError = continueOnError, + UseTransaction = false, } + ); + } + + private static void AssertRunnerFailure( + MigrationRunnerResult result, + bool continueOnError, + SchemaOperation before, + SchemaOperation broken, + SchemaOperation after, + ImmutableArray.Builder generated + ) + { + var failure = Assert.IsType(result); + Assert.False(string.IsNullOrWhiteSpace(failure.Value.Message)); + Assert.Contains( + "missing_runner_table", + failure.Value.Message, + StringComparison.OrdinalIgnoreCase + ); + Assert.Equal(continueOnError ? 3 : 2, generated.Count); + Assert.Same(before, generated[0]); + Assert.Same(broken, generated[1]); + if (continueOnError) + { + Assert.Same(after, generated[2]); } } + + private static void AssertBaseline(MigrationTarget target) + { + AssertTableExists(target, "runner_baseline", expected: true); + AssertColumnExists(target, "runner_baseline", "id", expected: true); + Assert.False(ColumnIsNullable(target, "runner_baseline", "id")); + } } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/NativeAotMigrateSmokeTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/NativeAotMigrateSmokeTests.cs new file mode 100644 index 00000000..d058e768 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/NativeAotMigrateSmokeTests.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; + +namespace Nimblesite.DataProvider.Migration.Tests; + +/// +/// Implements [MIG-AOT-TEST]. Drives the PUBLISHED Native AOT binary as a +/// subprocess — the only thing that proves the native executable actually runs +/// (in-process Program.Main tests exercise the managed assembly, not the +/// AOT image). The binary path comes from the DATAPROVIDERMIGRATE_AOT_BIN +/// environment variable, which the AOT publish + CI set. When it is absent the +/// facts skip, so make test stays fast and self-contained while the CI +/// native job runs the real thing. +/// +public sealed class NativeAotMigrateSmokeTests +{ + private const string BinEnvVar = "DATAPROVIDERMIGRATE_AOT_BIN"; + + private static string? NativeBinaryPath() + { + var path = Environment.GetEnvironmentVariable(BinEnvVar); + return string.IsNullOrWhiteSpace(path) || !File.Exists(path) ? null : path; + } + + // Uses only SQLite-affinity-stable types (Text). VARCHAR/INT collapse to + // SQLite's TEXT/INTEGER affinity on inspection, which the integrity verifier + // would (correctly) flag as drift — that is a SQLite behaviour, not an AOT + // concern, so the smoke schema avoids it to keep the test focused on the + // native binary running the full pipeline. + private const string SmokeSchema = """ + name: aot_smoke + tables: + - name: Widget + columns: + - name: Id + type: Text + isNullable: false + - name: Label + type: Text + isNullable: false + - name: Quantity + type: Text + isNullable: false + primaryKey: + columns: + - Id + indexes: + - name: idx_widget_label + columns: + - Label + isUnique: true + """; + + [SkippableFact] + public void NativeBinary_MigratesSqliteSchema_ExitZero() + { + var bin = NativeBinaryPath(); + Skip.If(bin is null, $"{BinEnvVar} not set — native AOT binary not published."); + + var (schemaPath, dbPath) = WriteFixtures(); + try + { + var result = Run(bin!, schemaPath, dbPath); + + Assert.True(result.ExitCode == 0, userMessage: result.Output); + Assert.True(File.Exists(dbPath), userMessage: "SQLite database file was not created."); + Assert.Contains( + "Schema integrity check passed", + result.Output, + StringComparison.Ordinal + ); + } + finally + { + Cleanup(schemaPath, dbPath); + } + } + + [SkippableFact] + public void NativeBinary_RerunIsIdempotent_NoOperations() + { + var bin = NativeBinaryPath(); + Skip.If(bin is null, $"{BinEnvVar} not set — native AOT binary not published."); + + var (schemaPath, dbPath) = WriteFixtures(); + try + { + var first = Run(bin!, schemaPath, dbPath); + Assert.True(first.ExitCode == 0, userMessage: first.Output); + + var second = Run(bin!, schemaPath, dbPath); + Assert.True(second.ExitCode == 0, userMessage: second.Output); + Assert.Contains("Schema is up to date", second.Output, StringComparison.Ordinal); + } + finally + { + Cleanup(schemaPath, dbPath); + } + } + + [SkippableFact] + public void NativeBinary_ExportCommand_ReportsUnsupported() + { + var bin = NativeBinaryPath(); + Skip.If(bin is null, $"{BinEnvVar} not set — native AOT binary not published."); + + // Implements [MIG-AOT-EXPORT]: export is compiled out of the native build. + var result = RunRaw( + bin!, + ["export", "--assembly", "x.dll", "--type", "T", "--output", "o.yaml"] + ); + + Assert.True(result.ExitCode != 0, userMessage: result.Output); + Assert.Contains("not available in the native", result.Output, StringComparison.Ordinal); + } + + private static (string SchemaPath, string DbPath) WriteFixtures() + { + var id = Guid.NewGuid().ToString("N"); + var schemaPath = Path.Combine(Path.GetTempPath(), $"aot-smoke-{id}.yaml"); + var dbPath = Path.Combine(Path.GetTempPath(), $"aot-smoke-{id}.db"); + File.WriteAllText(schemaPath, SmokeSchema); + return (schemaPath, dbPath); + } + + private static (int ExitCode, string Output) Run( + string bin, + string schemaPath, + string dbPath + ) => + RunRaw( + bin, + ["migrate", "--schema", schemaPath, "--output", dbPath, "--provider", "sqlite"] + ); + + private static (int ExitCode, string Output) RunRaw(string bin, string[] args) + { + var startInfo = new ProcessStartInfo + { + FileName = bin, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = + Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start {bin}"); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + + // 60s ceiling keeps a hung native process from stalling the suite; a + // timeout is a failure per the project testing rules. + Assert.True( + process.WaitForExit(milliseconds: 60_000), + userMessage: "Native binary did not exit within 60s." + ); + + return (process.ExitCode, string.Concat(stdout, stderr)); + } + + private static void Cleanup(string schemaPath, string dbPath) + { + TryDelete(schemaPath); + TryDelete(dbPath); + } + + private static void TryDelete(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj index 05d5d035..3566a69e 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj @@ -10,13 +10,18 @@ + + all runtime; build; native; contentfiles; analyzers + + all @@ -25,11 +30,12 @@ + - + diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresCheckConstraintReplacementIssue57Tests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresCheckConstraintReplacementIssue57Tests.cs index 2d9213db..4eed3a69 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresCheckConstraintReplacementIssue57Tests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresCheckConstraintReplacementIssue57Tests.cs @@ -20,16 +20,22 @@ public async Task Calculate_TableCheckExpressionChanged_RegeneratesConstraintInP try { - Apply( + PostgresTestDb.Apply( connection, - Calculate(Inspect(connection), SchemaWithKindCheck("usage_events_kind_check_v1")) + PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithKindCheck("usage_events_kind_check_v1"), + Logger + ), + Logger ); Assert.True(LiveExpressionContains(connection, "input_tokens")); Assert.False(LiveExpressionContains(connection, "llm_input_tokens")); - var upgrade = Calculate( - Inspect(connection), - SchemaWithKindCheck("usage_events_kind_check_v2") + var upgrade = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithKindCheck("usage_events_kind_check_v2"), + Logger ); Assert.Contains( upgrade, @@ -39,14 +45,15 @@ op is DropCheckConstraintOperation drop ); Assert.Contains(upgrade, op => op is AddCheckConstraintOperation); - Apply(connection, upgrade); + PostgresTestDb.Apply(connection, upgrade, Logger); Assert.True(LiveExpressionContains(connection, "llm_input_tokens")); Assert.False(LiveExpressionContains(connection, "'input_tokens'")); - var replay = Calculate( - Inspect(connection), - SchemaWithKindCheck("usage_events_kind_check_v2") + var replay = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithKindCheck("usage_events_kind_check_v2"), + Logger ); Assert.DoesNotContain(replay, op => op is DropCheckConstraintOperation); Assert.DoesNotContain(replay, op => op is AddCheckConstraintOperation); @@ -106,52 +113,6 @@ private static SchemaDefinition SchemaWithKindCheck(string version) }; } - private static SchemaDefinition Inspect(NpgsqlConnection connection) - { - var result = PostgresSchemaInspector.Inspect(connection, SchemaName, Logger); - if (result is SchemaResultOk ok) - { - return ok.Value; - } - Assert.Fail("Expected PostgreSQL schema inspection to succeed."); - return new SchemaDefinition { Name = "failed", Tables = [] }; - } - - private static IReadOnlyList Calculate( - SchemaDefinition current, - SchemaDefinition desired - ) - { - var result = SchemaDiff.Calculate( - current: current, - desired: desired, - allowDestructive: false, - logger: Logger - ); - if (result is OperationsResultOk ok) - { - return ok.Value; - } - Assert.Fail("Expected PostgreSQL schema diff to succeed."); - return []; - } - - private static void Apply( - NpgsqlConnection connection, - IReadOnlyList operations - ) - { - var result = MigrationRunner.Apply( - connection: connection, - operations: operations, - generateDdl: PostgresDdlGenerator.Generate, - options: MigrationOptions.Default, - logger: Logger - ); - var failure = result is MigrationApplyResultError error ? error.Value.ToString() : ""; - Assert.True(result is MigrationApplyResultOk, $"Migration failed: {failure}"); - } - private static bool LiveExpressionContains(NpgsqlConnection connection, string needle) { using var command = connection.CreateCommand(); diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresCheckConstraintRerunTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresCheckConstraintRerunTests.cs new file mode 100644 index 00000000..b9a4cee2 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresCheckConstraintRerunTests.cs @@ -0,0 +1,83 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +[Collection(PostgresTestSuite.Name)] +public sealed class PostgresCheckConstraintRerunTests(PostgresContainerFixture fixture) +{ + private static readonly ILogger Logger = NullLogger.Instance; + + [Fact] + public void ExistingOptionalChecks_AdditiveAndDestructiveRerunsAreEmpty() + { + using var connection = fixture.CreateDatabase("optional_check_reruns"); + var schema = Schema(); + PostgresTestDb.Apply( + connection, + PostgresTestDb.Calculate(PostgresTestDb.Inspect(connection, Logger), schema, Logger), + Logger + ); + + var additive = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + schema, + Logger + ); + var destructive = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + schema, + Logger, + allowDestructive: true + ); + + Assert.Empty(additive); + Assert.Empty(destructive); + } + + private static SchemaDefinition Schema() => + new() + { + Name = "optional_check_reruns", + Tables = + [ + new TableDefinition + { + Name = "agent_configs", + Columns = + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + Checked( + "workspace_host_kind", + "workspace_host_kind IS NULL OR workspace_host_kind IN ('fly','docker')" + ), + Checked( + "workspace_size", + "workspace_size IS NULL OR workspace_size IN ('small','medium','large')" + ), + Checked( + "provisioning_bundle_sha256", + "provisioning_bundle_sha256 IS NULL OR provisioning_bundle_sha256 ~ '^[0-9a-f]{64}$'" + ), + new ColumnDefinition + { + Name = "workspace_dev_access", + Type = PortableTypes.Boolean, + CheckConstraint = "workspace_dev_access = false", + }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + }, + ], + }; + + private static ColumnDefinition Checked(string name, string expression) => + new() + { + Name = name, + Type = PortableTypes.Text, + CheckConstraint = expression, + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresColumnCheckConstraintTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresColumnCheckConstraintTests.cs index 7c5b525a..b5deda75 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresColumnCheckConstraintTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresColumnCheckConstraintTests.cs @@ -50,7 +50,15 @@ public async Task ApplyYaml_WhenColumnCheckConstraintHasName_CreatesNamedPostgre """ ); - Apply(connection, Calculate(Inspect(connection), schema)); + PostgresTestDb.Apply( + connection, + PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + schema, + Logger + ), + Logger + ); Assert.Equal( [ @@ -61,14 +69,20 @@ public async Task ApplyYaml_WhenColumnCheckConstraintHasName_CreatesNamedPostgre ConstraintNames(connection) ); - var inspected = Inspect(connection).Tables.Single(t => t.Name == "bundles"); + var inspected = PostgresTestDb + .Inspect(connection, Logger) + .Tables.Single(t => t.Name == "bundles"); var sha256 = inspected.Columns.Single(c => c.Name == "sha256"); var sizeBytes = inspected.Columns.Single(c => c.Name == "size_bytes"); Assert.Equal("bundles_sha256_fmt_chk", sha256.CheckConstraintName); Assert.Equal("bundles_size_bytes_nonneg_chk", sizeBytes.CheckConstraintName); Assert.DoesNotContain( - Calculate(Inspect(connection), schema), + PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + schema, + Logger + ), operation => operation is AddCheckConstraintOperation ); } @@ -79,50 +93,6 @@ public async Task ApplyYaml_WhenColumnCheckConstraintHasName_CreatesNamedPostgre } } - private static SchemaDefinition Inspect(NpgsqlConnection connection) - { - var result = PostgresSchemaInspector.Inspect(connection, SchemaName, Logger); - if (result is SchemaResultOk ok) - { - return ok.Value; - } - - Assert.Fail("Expected PostgreSQL schema inspection to succeed."); - return Schema.Define("failed").Build(); - } - - private static IReadOnlyList Calculate( - SchemaDefinition current, - SchemaDefinition desired - ) - { - var result = SchemaDiff.Calculate(current, desired, logger: Logger); - if (result is OperationsResultOk ok) - { - return ok.Value; - } - - Assert.Fail("Expected PostgreSQL schema diff to succeed."); - return []; - } - - private static void Apply( - NpgsqlConnection connection, - IReadOnlyList operations - ) - { - var result = MigrationRunner.Apply( - connection, - operations, - PostgresDdlGenerator.Generate, - MigrationOptions.Default, - Logger - ); - var failure = result is MigrationApplyResultError error ? error.Value.ToString() : ""; - - Assert.True(result is MigrationApplyResultOk, $"Migration failed: {failure}"); - } - private static string[] ConstraintNames(NpgsqlConnection connection) { using var command = connection.CreateCommand(); diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresDeclarativeTriggerE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresDeclarativeTriggerE2ETests.cs new file mode 100644 index 00000000..429a2025 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresDeclarativeTriggerE2ETests.cs @@ -0,0 +1,272 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [MIG-TRIGGER-PG] and [MIG-TRIGGER-DIFF] from +// docs/specs/declarative-triggers-spec.md (GitHub issue 82). + +/// +/// E2E tests for declarative BEFORE-trigger guards against a real +/// Testcontainers postgres instance. The schema declares a last-owner guard +/// on tenant_members: demoting or deleting the only 'owner' row of a tenant +/// must fail with the declared error message. +/// +[Collection(PostgresTestSuite.Name)] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA1001:Types that own disposable fields should be disposable", + Justification = "Disposed via IAsyncLifetime.DisposeAsync" +)] +public sealed class PostgresDeclarativeTriggerE2ETests(PostgresContainerFixture fixture) + : IAsyncLifetime +{ + private NpgsqlConnection _connection = null!; + private readonly ILogger _logger = NullLogger.Instance; + + public async Task InitializeAsync() + { + _connection = await fixture.CreateDatabaseAsync("trigger_e2e").ConfigureAwait(false); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync().ConfigureAwait(false); + } + + private const string GuardYaml = """ + name: tenant_app + tables: + - name: tenant_members + columns: + - name: id + type: Uuid + isNullable: false + - name: tenant_id + type: Uuid + isNullable: false + - name: role + type: VarChar(50) + isNullable: false + primaryKey: + columns: + - id + triggers: + - name: assert_not_last_owner + events: [Update, Delete] + raiseWhen: | + old.role = 'owner' and not exists( + tenant_members + |> filter(fn(m) => m.tenant_id = old.tenant_id and m.role = 'owner' and m.id <> old.id) + ) + errorMessage: cannot remove the last owner of a tenant + """; + + [Fact] + public void PgTriggerGuard_BlocksDeleteOfLastOwner() + { + ApplyGuardSchema(); + var owner = Guid.NewGuid(); + var tenant = Guid.NewGuid(); + InsertMember(owner, tenant, "owner"); + InsertMember(Guid.NewGuid(), tenant, "member"); + + var ex = Assert.Throws(() => DeleteMember(owner)); + + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + Assert.Equal(2, CountMembers()); + } + + [Fact] + public void PgTriggerGuard_AllowsDeleteWhenAnotherOwnerExists() + { + ApplyGuardSchema(); + var ownerA = Guid.NewGuid(); + var tenant = Guid.NewGuid(); + InsertMember(ownerA, tenant, "owner"); + InsertMember(Guid.NewGuid(), tenant, "owner"); + + DeleteMember(ownerA); + + Assert.Equal(1, CountMembers()); + } + + [Fact] + public void PgTriggerGuard_BlocksDemoteOfLastOwner() + { + ApplyGuardSchema(); + var owner = Guid.NewGuid(); + var tenant = Guid.NewGuid(); + InsertMember(owner, tenant, "owner"); + InsertMember(Guid.NewGuid(), tenant, "member"); + + var ex = Assert.Throws(() => DemoteMember(owner)); + + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void PgTrigger_RediffAfterApply_ProducesNoOperations() + { + ApplyGuardSchema(); + + var current = ( + (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) + ).Value; + var ops = ( + (OperationsResultOk) + SchemaDiff.Calculate( + current, + SchemaYamlSerializer.FromYaml(GuardYaml), + logger: _logger + ) + ).Value; + + Assert.Empty(ops); + } + + private const string PascalCaseGuardYaml = """ + name: tenant_app + tables: + - name: TenantMembers + columns: + - name: Id + type: Uuid + isNullable: false + - name: TenantId + type: Uuid + isNullable: false + - name: Role + type: VarChar(50) + isNullable: false + primaryKey: + columns: + - Id + triggers: + - name: assert_not_last_owner + events: [Update, Delete] + raiseWhen: | + old.Role = 'owner' and not exists( + TenantMembers + |> filter(fn(m) => m.TenantId = old.TenantId and m.Role = 'owner' and m.Id <> old.Id) + ) + errorMessage: cannot remove the last owner of a tenant + """; + + [Fact] + public void PgTriggerGuard_PascalCaseIdentifiers_EnforcedAtRuntime() + { + // Implements [MIG-TRIGGER-GUARD-LQL]: exists() pipeline columns must + // be quoted on Postgres or mixed-case columns fold to lowercase and + // every UPDATE/DELETE fails with 42703 at trigger-fire time. + PostgresTestDb.ApplySchema( + _connection, + SchemaYamlSerializer.FromYaml(PascalCaseGuardYaml), + _logger + ); + var ownerA = Guid.NewGuid(); + var ownerB = Guid.NewGuid(); + var tenant = Guid.NewGuid(); + InsertPascalMember(ownerA, tenant, "owner"); + InsertPascalMember(ownerB, tenant, "owner"); + + // Two owners: deleting one must succeed (trigger fires cleanly). + Execute("DELETE FROM \"public\".\"TenantMembers\" WHERE \"Id\" = @i", ownerA); + + // Last owner: must be blocked by the declared guard message. + var ex = Assert.Throws(() => + Execute("DELETE FROM \"public\".\"TenantMembers\" WHERE \"Id\" = @i", ownerB) + ); + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void PgTrigger_DestructiveRerun_PreservesUnmanagedTriggerAndGuardFunction() + { + // Implements [MIG-TRIGGER-PG]: only usr_-prefixed triggers are + // migration-managed. Unmanaged triggers (e.g. Sync change tracking) + // must survive destructive runs, and the guard function must not be + // dropped out from under its trigger. + ApplyGuardSchema(); + Execute( + """ + CREATE TRIGGER zz_unmanaged_touch BEFORE UPDATE ON "public"."tenant_members" + FOR EACH ROW EXECUTE FUNCTION suppress_redundant_updates_trigger() + """ + ); + + PostgresTestDb.ApplySchema( + _connection, + SchemaYamlSerializer.FromYaml(GuardYaml), + _logger, + allowDestructive: true + ); + + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM pg_trigger WHERE tgname = 'zz_unmanaged_touch'"; + Assert.Equal(1L, Assert.IsType(cmd.ExecuteScalar())); + + // Guard still enforced after the destructive rerun. + var owner = Guid.NewGuid(); + var tenant = Guid.NewGuid(); + InsertMember(owner, tenant, "owner"); + var ex = Assert.Throws(() => DeleteMember(owner)); + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + } + + private void Execute(string sql, Guid? id = null) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = sql; + if (id is { } value) + { + cmd.Parameters.AddWithValue("@i", value); + } + cmd.ExecuteNonQuery(); + } + + private void InsertPascalMember(Guid id, Guid tenantId, string role) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = + "INSERT INTO \"public\".\"TenantMembers\"(\"Id\", \"TenantId\", \"Role\") VALUES (@i, @t, @r)"; + cmd.Parameters.AddWithValue("@i", id); + cmd.Parameters.AddWithValue("@t", tenantId); + cmd.Parameters.AddWithValue("@r", role); + cmd.ExecuteNonQuery(); + } + + private void ApplyGuardSchema() => + PostgresTestDb.ApplySchema(_connection, SchemaYamlSerializer.FromYaml(GuardYaml), _logger); + + private void InsertMember(Guid id, Guid tenantId, string role) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = + "INSERT INTO \"public\".\"tenant_members\"(id, tenant_id, role) VALUES (@i, @t, @r)"; + cmd.Parameters.AddWithValue("@i", id); + cmd.Parameters.AddWithValue("@t", tenantId); + cmd.Parameters.AddWithValue("@r", role); + cmd.ExecuteNonQuery(); + } + + private void DeleteMember(Guid id) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "DELETE FROM \"public\".\"tenant_members\" WHERE id = @i"; + cmd.Parameters.AddWithValue("@i", id); + cmd.ExecuteNonQuery(); + } + + private void DemoteMember(Guid id) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "UPDATE \"public\".\"tenant_members\" SET role = 'member' WHERE id = @i"; + cmd.Parameters.AddWithValue("@i", id); + cmd.ExecuteNonQuery(); + } + + private long CountMembers() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM \"public\".\"tenant_members\""; + return Assert.IsType(cmd.ExecuteScalar()); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresDropConstraintBackedIndexTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresDropConstraintBackedIndexTests.cs index 0c0a0480..0d58e92c 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresDropConstraintBackedIndexTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresDropConstraintBackedIndexTests.cs @@ -18,12 +18,25 @@ public async Task DropIndex_WhenIndexBacksUniqueConstraint_DropsConstraint() try { - Apply(connection, Calculate(Inspect(connection), SchemaWithUniqueConstraint())); - var upgrade = Calculate(Inspect(connection), SchemaWithoutUniqueConstraint(), true); + PostgresTestDb.Apply( + connection, + PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithUniqueConstraint(), + Logger + ), + Logger + ); + var upgrade = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithoutUniqueConstraint(), + Logger, + allowDestructive: true + ); Assert.Contains(upgrade, IsConstraintBackedIndexDrop); - Apply(connection, upgrade, MigrationOptions.Destructive); + PostgresTestDb.Apply(connection, upgrade, Logger, MigrationOptions.Destructive); Assert.False(ConstraintExists(connection)); Assert.False(IndexExists(connection)); @@ -47,10 +60,25 @@ public async Task Calculate_WhenUniqueConstraintSchemaConverged_DoesNotDropBacki { var desired = SchemaWithUniqueConstraint(); - Apply(connection, Calculate(Inspect(connection), desired)); + PostgresTestDb.Apply( + connection, + PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + desired, + Logger + ), + Logger + ); - var inspected = Inspect(connection).Tables.Single(t => t.Name == TableName); - var converged = Calculate(Inspect(connection), desired, true); + var inspected = PostgresTestDb + .Inspect(connection, Logger) + .Tables.Single(t => t.Name == TableName); + var converged = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + desired, + Logger, + allowDestructive: true + ); Assert.Contains( inspected.UniqueConstraints, @@ -64,7 +92,7 @@ public async Task Calculate_WhenUniqueConstraintSchemaConverged_DoesNotDropBacki operation => operation is AddUniqueConstraintOperation ); - Apply(connection, converged, MigrationOptions.Destructive); + PostgresTestDb.Apply(connection, converged, Logger, MigrationOptions.Destructive); Assert.True(ConstraintExists(connection)); Assert.True(IndexExists(connection)); @@ -101,52 +129,6 @@ private static SchemaDefinition SchemaWithoutUniqueConstraint() => ) .Build(); - private static SchemaDefinition Inspect(NpgsqlConnection connection) - { - var result = PostgresSchemaInspector.Inspect(connection, SchemaName, Logger); - if (result is SchemaResultOk ok) - { - return ok.Value; - } - - Assert.Fail("Expected PostgreSQL schema inspection to succeed."); - return Schema.Define("failed").Build(); - } - - private static IReadOnlyList Calculate( - SchemaDefinition current, - SchemaDefinition desired, - bool allowDestructive = false - ) - { - var result = SchemaDiff.Calculate(current, desired, allowDestructive, Logger); - if (result is OperationsResultOk ok) - { - return ok.Value; - } - - Assert.Fail("Expected PostgreSQL schema diff to succeed."); - return []; - } - - private static void Apply( - NpgsqlConnection connection, - IReadOnlyList operations, - MigrationOptions? options = null - ) - { - var result = MigrationRunner.Apply( - connection, - operations, - PostgresDdlGenerator.Generate, - options ?? MigrationOptions.Default, - Logger - ); - var failure = result is MigrationApplyResultError error ? error.Value.ToString() : ""; - - Assert.True(result is MigrationApplyResultOk, $"Migration failed: {failure}"); - } - private static bool IsConstraintBackedIndexDrop(SchemaOperation operation) => operation is DropIndexOperation diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs index 1103245b..a3dfef50 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs @@ -6,23 +6,23 @@ public sealed class PostgresGrantRunAsE2ETests(PostgresContainerFixture fixture) [Fact] public void GrantRunAs_AppliesNapAuthShapeGrantsThroughSchemaOwner() { - var suffix = Guid.NewGuid().ToString("N")[..8]; - var owner = $"grant_owner_{suffix}"; - var migrate = $"grant_migrate_{suffix}"; - var appUser = $"grant_app_user_{suffix}"; + var (suffix, owner, migrate, appUser) = NewGrantRoles(); var appAdmin = $"grant_app_admin_{suffix}"; var schema = $"auth_{suffix}"; using var connection = fixture.CreateDatabase("grant_run_as"); - Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN"); - Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'"); - Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN"); - Exec(connection, $"CREATE ROLE {Q(appAdmin)} NOLOGIN"); - Exec(connection, $"GRANT {Q(owner)} TO {Q("test")}"); - Exec(connection, $"GRANT {Q(owner)} TO {Q(migrate)}"); - Exec(connection, $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}"); - Exec(connection, $"CREATE SCHEMA {Q(schema)} AUTHORIZATION {Q(owner)}"); - Exec( + PostgresTestDb.Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN"); + PostgresTestDb.Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'"); + PostgresTestDb.Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN"); + PostgresTestDb.Exec(connection, $"CREATE ROLE {Q(appAdmin)} NOLOGIN"); + PostgresTestDb.Exec(connection, $"GRANT {Q(owner)} TO {Q("test")}"); + PostgresTestDb.Exec(connection, $"GRANT {Q(owner)} TO {Q(migrate)}"); + PostgresTestDb.Exec( + connection, + $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}" + ); + PostgresTestDb.Exec(connection, $"CREATE SCHEMA {Q(schema)} AUTHORIZATION {Q(owner)}"); + PostgresTestDb.Exec( connection, $"SET ROLE {Q(owner)}; CREATE TABLE {Q(schema)}.{Q("users")}(id uuid PRIMARY KEY); RESET ROLE" ); @@ -46,16 +46,16 @@ public void GrantRunAs_AppliesNapAuthShapeGrantsThroughSchemaOwner() [Fact] public void GrantRunAs_MissingRoleMembership_ReturnsClearGrantToMessage() { - var suffix = Guid.NewGuid().ToString("N")[..8]; - var owner = $"grant_owner_{suffix}"; - var migrate = $"grant_migrate_{suffix}"; - var appUser = $"grant_app_user_{suffix}"; + var (_, owner, migrate, appUser) = NewGrantRoles(); using var connection = fixture.CreateDatabase("grant_run_as_missing"); - Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN"); - Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'"); - Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN"); - Exec(connection, $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}"); + PostgresTestDb.Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN"); + PostgresTestDb.Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'"); + PostgresTestDb.Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN"); + PostgresTestDb.Exec( + connection, + $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}" + ); using var migrateConnection = OpenRoleConnection(connection, migrate); var result = MigrationRunner.Apply( @@ -83,6 +83,17 @@ public void GrantRunAs_MissingRoleMembership_ReturnsClearGrantToMessage() Assert.Contains($"GRANT \"{owner}\" TO \"{migrate}\"", error.Message); } + private static (string Suffix, string Owner, string Migrate, string AppUser) NewGrantRoles() + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + return ( + suffix, + $"grant_owner_{suffix}", + $"grant_migrate_{suffix}", + $"grant_app_user_{suffix}" + ); + } + private static IReadOnlyList NapAuthGrants( string schema, string owner, @@ -168,13 +179,6 @@ private static bool ScalarBool( return command.ExecuteScalar() is true; } - private static void Exec(NpgsqlConnection connection, string sql) - { - using var command = connection.CreateCommand(); - command.CommandText = sql; - command.ExecuteNonQuery(); - } - private static NpgsqlConnection OpenRoleConnection( NpgsqlConnection adminConnection, string role diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresLqlOnlyE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresLqlOnlyE2ETests.cs index 5693b692..c91172fe 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresLqlOnlyE2ETests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresLqlOnlyE2ETests.cs @@ -40,7 +40,7 @@ public async Task DisposeAsync() /// private static void BootstrapRolesAndGucReaders(NpgsqlConnection conn) { - Exec( + PostgresTestDb.Exec( conn, """ DO $$ BEGIN @@ -68,7 +68,7 @@ SELECT NULLIF(current_setting('rls.tenant_id', true), '')::uuid /// membership fns that reference tenant_members. /// private static void BootstrapMembershipFns(NpgsqlConnection conn) => - Exec( + PostgresTestDb.Exec( conn, """ CREATE OR REPLACE FUNCTION is_member(u uuid, t uuid) RETURNS bool @@ -81,29 +81,55 @@ SELECT EXISTS ( """ ); - private void ApplyAndGrant(SchemaDefinition desired, params string[] tableNames) + /// + /// Inspects the live public schema, diffs it against the desired schema, + /// applies the resulting operations, and asserts the migration succeeded. + /// + internal static void ApplySchema( + NpgsqlConnection connection, + SchemaDefinition desired, + ILogger logger + ) { var current = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) + (SchemaResultOk)PostgresSchemaInspector.Inspect(connection, "public", logger) ).Value; var ops = ( - (OperationsResultOk)SchemaDiff.Calculate(current, desired, logger: _logger) + (OperationsResultOk)SchemaDiff.Calculate(current, desired, logger: logger) ).Value; + ApplyOperations(connection, ops, logger); + } + + /// + /// Applies pre-computed schema operations to Postgres and asserts the + /// migration succeeded. + /// + internal static void ApplyOperations( + NpgsqlConnection connection, + IReadOnlyList operations, + ILogger logger + ) + { var apply = MigrationRunner.Apply( - _connection, - ops, + connection, + operations, PostgresDdlGenerator.Generate, MigrationOptions.Default, - _logger + logger ); Assert.True( apply is MigrationApplyResultOk, $"Migration failed: {(apply as MigrationApplyResultError)?.Value}" ); + } + + private void ApplyAndGrant(SchemaDefinition desired, params string[] tableNames) + { + ApplySchema(connection: _connection, desired: desired, logger: _logger); foreach (var t in tableNames) { - Exec( + PostgresTestDb.Exec( _connection, $"GRANT USAGE ON SCHEMA public TO lql_user, lql_admin; " + $"GRANT SELECT,INSERT,UPDATE,DELETE ON \"public\".\"{t}\" TO lql_user, lql_admin" @@ -119,9 +145,17 @@ private static void SetSession( Guid? user ) { - Exec(conn, tx, $"SET LOCAL ROLE {role}"); - Exec(conn, tx, $"SET LOCAL rls.tenant_id = '{tenant?.ToString() ?? string.Empty}'"); - Exec(conn, tx, $"SET LOCAL rls.user_id = '{user?.ToString() ?? string.Empty}'"); + PostgresTestDb.Exec(conn, tx, $"SET LOCAL ROLE {role}"); + PostgresTestDb.Exec( + conn, + tx, + $"SET LOCAL rls.tenant_id = '{tenant?.ToString() ?? string.Empty}'" + ); + PostgresTestDb.Exec( + conn, + tx, + $"SET LOCAL rls.user_id = '{user?.ToString() ?? string.Empty}'" + ); } private static SchemaDefinition TenantMembersSchema() => @@ -263,11 +297,11 @@ public void LqlOnly_TenantIsolation_RealCrossUserBlock() var userB = Guid.NewGuid(); // Membership rows. - Exec( + PostgresTestDb.Exec( _connection, $"INSERT INTO tenant_members(id, user_id, tenant_id) VALUES ('{Guid.NewGuid()}', '{userA}', '{tenantA}')" ); - Exec( + PostgresTestDb.Exec( _connection, $"INSERT INTO tenant_members(id, user_id, tenant_id) VALUES ('{Guid.NewGuid()}', '{userB}', '{tenantB}')" ); @@ -277,7 +311,7 @@ public void LqlOnly_TenantIsolation_RealCrossUserBlock() using (var tx = _connection.BeginTransaction()) { SetSession(_connection, tx, "lql_user", tenantA, userA); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO docs_iso(id, tenant_id, title) VALUES ('{docA}', '{tenantA}', 'a')" @@ -290,7 +324,7 @@ public void LqlOnly_TenantIsolation_RealCrossUserBlock() using (var tx = _connection.BeginTransaction()) { SetSession(_connection, tx, "lql_user", tenantB, userB); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO docs_iso(id, tenant_id, title) VALUES ('{docB}', '{tenantB}', 'b')" @@ -351,12 +385,12 @@ public void LqlOnly_Admin_SeesAllTenants() using (var tx = _connection.BeginTransaction()) { SetSession(_connection, tx, "lql_admin", null, null); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO docs_admin(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{t1}', 'x')" ); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO docs_admin(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{t2}', 'y')" @@ -634,11 +668,11 @@ public void LqlOnly_NapMessagesShape_BareFnCallInLambdaBody_EnforcesIsolation() var tenantB = Guid.NewGuid(); var userA = Guid.NewGuid(); var userB = Guid.NewGuid(); - Exec( + PostgresTestDb.Exec( _connection, $"INSERT INTO tenant_members(id, user_id, tenant_id) VALUES ('{Guid.NewGuid()}', '{userA}', '{tenantA}')" ); - Exec( + PostgresTestDb.Exec( _connection, $"INSERT INTO tenant_members(id, user_id, tenant_id) VALUES ('{Guid.NewGuid()}', '{userB}', '{tenantB}')" ); @@ -646,11 +680,11 @@ public void LqlOnly_NapMessagesShape_BareFnCallInLambdaBody_EnforcesIsolation() // Conversations: convA in tenantA, convB in tenantB. var convA = Guid.NewGuid(); var convB = Guid.NewGuid(); - Exec( + PostgresTestDb.Exec( _connection, $"INSERT INTO conversations(id, tenant_id) VALUES ('{convA}', '{tenantA}')" ); - Exec( + PostgresTestDb.Exec( _connection, $"INSERT INTO conversations(id, tenant_id) VALUES ('{convB}', '{tenantB}')" ); @@ -660,7 +694,7 @@ public void LqlOnly_NapMessagesShape_BareFnCallInLambdaBody_EnforcesIsolation() using (var tx = _connection.BeginTransaction()) { SetSession(_connection, tx, "lql_user", tenantA, userA); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO messages(id, conversation_id, body) VALUES ('{msgA}', '{convA}', 'hi')" @@ -743,19 +777,4 @@ public void LqlOnly_DropPolicy_AllowDestructive_RemovesIt() cmd.CommandText = "SELECT COUNT(*) FROM pg_policies WHERE tablename='docs_drop'"; Assert.Equal(0L, (long)cmd.ExecuteScalar()!); } - - private static void Exec(NpgsqlConnection conn, string sql) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } - - private static void Exec(NpgsqlConnection conn, NpgsqlTransaction tx, string sql) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs index 227bdbe9..ac0eed3e 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs @@ -106,9 +106,7 @@ public void CreateDatabaseFromScratch_MultipleTablesWithForeignKeys_Success() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) @@ -128,9 +126,7 @@ public void CreateDatabaseFromScratch_MultipleTablesWithForeignKeys_Success() $"Migration failed: {(result as MigrationApplyResultError)?.Value}" ); - var inspected = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var inspected = InspectPublic(); Assert.Contains(inspected.Tables, t => t.Name == "customers"); Assert.Contains(inspected.Tables, t => t.Name == "invoices"); @@ -154,9 +150,7 @@ public void UpgradeExistingDatabase_AddColumn_Success() ) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var v1Ops = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) ).Value; @@ -183,9 +177,7 @@ public void UpgradeExistingDatabase_AddColumn_Success() .Build(); // Act - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var upgradeOps = ( (OperationsResultOk)SchemaDiff.Calculate(currentSchema, v2, logger: _logger) @@ -206,9 +198,7 @@ public void UpgradeExistingDatabase_AddColumn_Success() // Assert Assert.True(result is MigrationApplyResultOk); - var finalSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var finalSchema = InspectPublic(); var products = finalSchema.Tables.Single(t => t.Name == "products"); Assert.Equal(4, products.Columns.Count); } @@ -228,9 +218,7 @@ public void UpgradeExistingDatabase_AddTable_Success() ) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var v1Ops = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) ).Value; @@ -264,9 +252,7 @@ public void UpgradeExistingDatabase_AddTable_Success() .Build(); // Act - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var upgradeOps = ( (OperationsResultOk)SchemaDiff.Calculate(currentSchema, v2, logger: _logger) @@ -286,9 +272,7 @@ public void UpgradeExistingDatabase_AddTable_Success() // Assert Assert.True(result is MigrationApplyResultOk); - var finalSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var finalSchema = InspectPublic(); Assert.Contains(finalSchema.Tables, t => t.Name == "items"); } @@ -308,9 +292,7 @@ public void UpgradeExistingDatabase_AddIndex_Success() ) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var v1Ops = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) ).Value; @@ -337,9 +319,7 @@ public void UpgradeExistingDatabase_AddIndex_Success() .Build(); // Act - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var upgradeOps = ( (OperationsResultOk)SchemaDiff.Calculate(currentSchema, v2, logger: _logger) @@ -359,9 +339,7 @@ public void UpgradeExistingDatabase_AddIndex_Success() // Assert Assert.True(result is MigrationApplyResultOk); - var finalSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var finalSchema = InspectPublic(); var logs = finalSchema.Tables.Single(t => t.Name == "logs"); Assert.Contains(logs.Indexes, i => i.Name == "idx_logs_level"); } @@ -384,9 +362,7 @@ public void Migration_IsIdempotent_NoErrorOnRerun() // Act - Run migration twice for (var i = 0; i < 2; i++) { - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(currentSchema, schema, logger: _logger) @@ -430,9 +406,7 @@ public void CreateTable_PostgresNativeTypes_Success() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -487,9 +461,7 @@ public void CreateTable_MixedCaseColumnCheckConstraint_PreservesIdentifierCase() ) .Build(); - var current = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var current = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(current, schema, logger: _logger) ).Value; @@ -528,9 +500,7 @@ public void ExpressionIndex_CreateWithLowerFunction_Success() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -584,9 +554,7 @@ public void ExpressionIndex_EnforcesCaseInsensitiveUniqueness() ) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -650,9 +618,7 @@ public void ExpressionIndex_MultiExpression_CompositeIndexSuccess() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -723,9 +689,7 @@ public void ExpressionIndex_MultiExpression_AllowsSameNameDifferentSuburb() ) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -790,9 +754,7 @@ public void ExpressionIndex_Idempotent_NoErrorOnRerun() // but CREATE INDEX IF NOT EXISTS ensures idempotency at the database level for (var i = 0; i < 2; i++) { - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(currentSchema, schema, logger: _logger) @@ -845,9 +807,7 @@ public void UpgradeIndex_ColumnToExpression_RequiresDropAndCreate() .Build(); // Apply v1 - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var v1Ops = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) ).Value; @@ -873,9 +833,7 @@ public void UpgradeIndex_ColumnToExpression_RequiresDropAndCreate() .Build(); // Act - Calculate upgrade operations - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var upgradeOps = ( (OperationsResultOk) SchemaDiff.Calculate(currentSchema, v2, allowDestructive: true, logger: _logger) @@ -928,9 +886,7 @@ public void UpgradeIndex_ExpressionToColumn_RequiresDropAndCreate() .Build(); // Apply v1 - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var v1Ops = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) ).Value; @@ -956,9 +912,7 @@ public void UpgradeIndex_ExpressionToColumn_RequiresDropAndCreate() .Build(); // Act - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var upgradeOps = ( (OperationsResultOk) SchemaDiff.Calculate(currentSchema, v2, allowDestructive: true, logger: _logger) @@ -1013,9 +967,7 @@ public void UpgradeIndex_SameNameDifferentType_NotDetectedWithoutDestructive() ) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var v1Ops = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) ).Value; @@ -1042,9 +994,7 @@ public void UpgradeIndex_SameNameDifferentType_NotDetectedWithoutDestructive() .Build(); // Act - Calculate without destructive flag - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var upgradeOps = ( (OperationsResultOk) SchemaDiff.Calculate(currentSchema, v2, allowDestructive: false, logger: _logger) @@ -1069,9 +1019,7 @@ public void Destructive_DropTable_AllowedWithOption() .Table("public", "dropme", t => t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var v1Ops = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) ).Value; @@ -1094,9 +1042,7 @@ public void Destructive_DropTable_AllowedWithOption() .Build(); // Act - var currentSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var currentSchema = InspectPublic(); var operations = ( (OperationsResultOk) @@ -1117,9 +1063,7 @@ public void Destructive_DropTable_AllowedWithOption() // Assert Assert.True(result is MigrationApplyResultOk); - var finalSchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var finalSchema = InspectPublic(); Assert.DoesNotContain(finalSchema.Tables, t => t.Name == "dropme"); Assert.Contains(finalSchema.Tables, t => t.Name == "keepers"); } @@ -1148,9 +1092,7 @@ public void LqlDefault_NowFunction_GeneratesCurrentTimestamp() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -1197,9 +1139,7 @@ public void LqlDefault_GenUuidFunction_GeneratesValidUuid() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -1265,9 +1205,7 @@ public void LqlDefault_BooleanTrue_GeneratesCorrectValue() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -1319,9 +1257,7 @@ public void LqlDefault_NumericLiterals_GeneratesCorrectValues() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -1377,9 +1313,7 @@ public void LqlDefault_StringLiteral_GeneratesCorrectValue() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -1447,9 +1381,7 @@ public void LqlDefault_AllTypesInOneTable_WorksTogether() .Build(); // Act - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -1497,8 +1429,7 @@ public void LqlDefault_AllTypesInOneTable_WorksTogether() // preinstalled). These tests prove the full pipeline — YAML schema // -> VectorType -> PostgresDdlGenerator -> CREATE EXTENSION vector // -> CREATE TABLE -> INSERT -> SELECT -> round-trip — works against - // a real database, which is the exact path HealthcareSamples needs - // to unblock its embeddings column. + // a real database, including vector columns used by external consumers. // ═══════════════════════════════════════════════════════════════════ [Fact] @@ -1525,9 +1456,7 @@ public void CreateTableWithVectorColumn_MigratesAgainstPgvectorContainer_Success // If the source-side fix is still in progress, this test will // fail loudly on CREATE TABLE, proving the gap. - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) @@ -1549,9 +1478,7 @@ public void CreateTableWithVectorColumn_MigratesAgainstPgvectorContainer_Success ); // Assert — documents table exists - var inspected = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var inspected = InspectPublic(); Assert.Contains(inspected.Tables, t => t.Name == "documents"); // Assert — pgvector extension is installed in the database @@ -1622,9 +1549,7 @@ public void CreateTableWithVectorColumn_OpenAiLargeDim_Success() ) .Build(); - var emptySchema = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; + var emptySchema = InspectPublic(); var operations = ( (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) ).Value; @@ -1662,4 +1587,7 @@ private void InsertPatientGender(string gender) command.Parameters.AddWithValue("@gender", gender); command.ExecuteNonQuery(); } + + private SchemaDefinition InspectPublic() => + ((SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger)).Value; } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresPolicyCatalogRoundTripTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresPolicyCatalogRoundTripTests.cs new file mode 100644 index 00000000..3ceb51f6 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresPolicyCatalogRoundTripTests.cs @@ -0,0 +1,131 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [RLS-DIFF]. PostgreSQL catalog deparsing must not cause false policy drift. +[Collection(PostgresTestSuite.Name)] +public sealed class PostgresPolicyCatalogRoundTripTests(PostgresContainerFixture fixture) +{ + [Fact] + public void ExistingDatabase_CatalogDeparseRerunsCleanlyAndRoleLiteralChanges() + { + var connectionString = fixture.CreateDatabaseConnectionString("policy_catalog_roundtrip"); + using var connection = new NpgsqlConnection(connectionString); + connection.Open(); + var initialPath = Path.GetTempFileName(); + var changedPath = Path.GetTempFileName(); + try + { + File.WriteAllText(initialPath, SchemaYamlSerializer.ToYaml(PolicySchema("member"))); + File.WriteAllText(changedPath, SchemaYamlSerializer.ToYaml(PolicySchema("owner"))); + + var initial = RunCli(connectionString, initialPath); + Assert.True(initial.Code == 0, initial.Output); + Assert.Contains("Schema integrity check passed", initial.Output); + + var repeat = RunCli(connectionString, initialPath); + Assert.True(repeat.Code == 0, repeat.Output); + Assert.Contains("Schema is up to date — no operations needed", repeat.Output); + + var changed = RunCli(connectionString, changedPath); + Assert.True(changed.Code == 0, changed.Output); + Assert.Contains("AlterRlsPolicyOperation", changed.Output); + var rolePredicate = ReadPredicate(connection, "role_policy"); + Assert.Contains("'owner'", rolePredicate, StringComparison.Ordinal); + Assert.DoesNotContain("'member'", rolePredicate, StringComparison.Ordinal); + + var changedRepeat = RunCli(connectionString, changedPath); + Assert.True(changedRepeat.Code == 0, changedRepeat.Output); + Assert.Contains("Schema is up to date — no operations needed", changedRepeat.Output); + } + finally + { + File.Delete(initialPath); + File.Delete(changedPath); + } + } + + private static (int Code, string Output) RunCli(string connectionString, string path) => + MigrationCliConsole.Migrate( + schemaPath: path, + provider: "postgres", + output: connectionString + ); + + private static string ReadPredicate(NpgsqlConnection connection, string name) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT qual FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'children' AND policyname = @name + """; + command.Parameters.AddWithValue("name", name); + return Assert.IsType(command.ExecuteScalar()); + } + + private static SchemaDefinition PolicySchema(string secondRole) => + new() + { + Name = "policy_catalog_roundtrip", + Functions = + [ + new PostgresFunctionDefinition + { + Name = "app_is_system", + Returns = "text", + Body = "SELECT 'true'::text", + }, + ], + Tables = + [ + new TableDefinition + { + Name = "parents", + Columns = [Id()], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + }, + new TableDefinition + { + Name = "children", + Columns = + [ + Id(), + new ColumnDefinition { Name = "parent_id", Type = PortableTypes.Uuid }, + new ColumnDefinition { Name = "role", Type = PortableTypes.Text }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + RowLevelSecurity = new RlsPolicySetDefinition + { + Policies = + [ + new RlsPolicyDefinition + { + Name = "system_all", + Operations = [RlsOperation.All], + UsingLql = "app_is_system() = 'true'", + WithCheckLql = "app_is_system() = 'true'", + }, + new RlsPolicyDefinition + { + Name = "parent_policy", + Operations = [RlsOperation.Select], + UsingLql = "exists(parents |> filter(fn(p) => p.id = parent_id))", + }, + new RlsPolicyDefinition + { + Name = "role_policy", + Operations = [RlsOperation.Select], + UsingLql = $"role in ('admin', '{secondRole}')", + }, + ], + }, + }, + ], + }; + + private static ColumnDefinition Id() => + new() + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsE2ETests.cs index b897dbaf..a09f9532 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsE2ETests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsE2ETests.cs @@ -164,24 +164,7 @@ private static SchemaDefinition BuildGroupMembershipSchema() => private void ApplyAndForceRls(SchemaDefinition desired, params string[] tableNames) { - var current = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; - var ops = ( - (OperationsResultOk)SchemaDiff.Calculate(current, desired, logger: _logger) - ).Value; - - var apply = MigrationRunner.Apply( - _connection, - ops, - PostgresDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); - Assert.True( - apply is MigrationApplyResultOk, - $"Migration failed: {(apply as MigrationApplyResultError)?.Value}" - ); + PostgresTestDb.ApplySchema(_connection, desired, _logger); // Testcontainers postgres connects as a superuser with BYPASSRLS. // To exercise policies we need a non-bypassrls role and grant CRUD. diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsNapShapeTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsNapShapeTests.cs index da5a08ed..27b8ac57 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsNapShapeTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsNapShapeTests.cs @@ -38,7 +38,7 @@ public async Task DisposeAsync() /// private static void BootstrapNapPrelude(NpgsqlConnection conn) { - Exec( + PostgresTestDb.Exec( conn, """ DO $$ BEGIN @@ -132,29 +132,17 @@ private static TableDefinition MakeTenantTable(string tableName) => private void ApplyAndGrant(SchemaDefinition desired) { - var current = ( - (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger) - ).Value; - var ops = ( - (OperationsResultOk)SchemaDiff.Calculate(current, desired, logger: _logger) - ).Value; - var apply = MigrationRunner.Apply( - _connection, - ops, - PostgresDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); - Assert.True( - apply is MigrationApplyResultOk, - $"Migration failed: {(apply as MigrationApplyResultError)?.Value}" + PostgresLqlOnlyE2ETests.ApplySchema( + connection: _connection, + desired: desired, + logger: _logger ); // Grant CRUD on each table to the app roles (NAP would do this in its // own bootstrap; we replicate it here to make the test runnable). foreach (var t in desired.Tables) { - Exec( + PostgresTestDb.Exec( _connection, $"GRANT USAGE ON SCHEMA public TO nap_app_user, nap_app_admin; " + $"GRANT SELECT,INSERT,UPDATE,DELETE ON \"public\".\"{t.Name}\" TO nap_app_user, nap_app_admin" @@ -170,9 +158,17 @@ private static void SetSession( Guid? user ) { - Exec(conn, tx, $"SET LOCAL ROLE {role}"); - Exec(conn, tx, $"SET LOCAL rls.tenant_id = '{tenant?.ToString() ?? string.Empty}'"); - Exec(conn, tx, $"SET LOCAL rls.user_id = '{user?.ToString() ?? string.Empty}'"); + PostgresTestDb.Exec(conn, tx, $"SET LOCAL ROLE {role}"); + PostgresTestDb.Exec( + conn, + tx, + $"SET LOCAL rls.tenant_id = '{tenant?.ToString() ?? string.Empty}'" + ); + PostgresTestDb.Exec( + conn, + tx, + $"SET LOCAL rls.user_id = '{user?.ToString() ?? string.Empty}'" + ); } [Fact] @@ -213,7 +209,7 @@ public void NapShape_TenantIsolation_AppUserBlockedAcrossTenants() using (var tx = _connection.BeginTransaction()) { SetSession(_connection, tx, "nap_app_user", tenantA, userA); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO public.agent_configs(id, tenant_id, title) VALUES ('{configA}', '{tenantA}', 'a')" @@ -226,7 +222,7 @@ public void NapShape_TenantIsolation_AppUserBlockedAcrossTenants() using (var tx = _connection.BeginTransaction()) { SetSession(_connection, tx, "nap_app_user", tenantB, userB); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO public.agent_configs(id, tenant_id, title) VALUES ('{configB}', '{tenantB}', 'b')" @@ -263,12 +259,12 @@ public void NapShape_AdminRole_SeesEverything() using (var tx = _connection.BeginTransaction()) { SetSession(_connection, tx, "nap_app_admin", null, null); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO public.agent_configs(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{tenantA}', 'admin1')" ); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO public.agent_configs(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{tenantB}', 'admin2')" @@ -518,7 +514,7 @@ public void NapShape_Stress_HundredRowsAcrossTenants_PerUserCountsCorrect() var tenant = tenants[i % tenants.Count]; using var tx = _connection.BeginTransaction(); SetSession(_connection, tx, "nap_app_user", tenant, Guid.NewGuid()); - Exec( + PostgresTestDb.Exec( _connection, tx, $"INSERT INTO public.agent_configs(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{tenant}', 'row{i}')" @@ -538,19 +534,4 @@ public void NapShape_Stress_HundredRowsAcrossTenants_PerUserCountsCorrect() Assert.Equal(inserted[tenant], (int)seen); } } - - private static void Exec(NpgsqlConnection conn, string sql) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } - - private static void Exec(NpgsqlConnection conn, NpgsqlTransaction tx, string sql) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsPhaseUpgradeTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsPhaseUpgradeTests.cs new file mode 100644 index 00000000..c6ddf0ff --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresRlsPhaseUpgradeTests.cs @@ -0,0 +1,142 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [RLS-DIFF]. Policy alterations and replacements belong to the RLS CLI phase. +[Collection(PostgresTestSuite.Name)] +public sealed class PostgresRlsPhaseUpgradeTests(PostgresContainerFixture fixture) +{ + [Fact] + public void RlsPhase_UpdatesPredicateAndScopeWhileStructuralPhaseLeavesPoliciesAlone() + { + var connectionString = fixture.CreateDatabaseConnectionString("rls_phase_upgrade"); + using var connection = new NpgsqlConnection(connectionString); + connection.Open(); + var original = PolicySchema(changed: false); + var desired = PolicySchema(changed: true); + var originalPath = Path.GetTempFileName(); + var desiredPath = Path.GetTempFileName(); + try + { + File.WriteAllText(originalPath, SchemaYamlSerializer.ToYaml(original)); + File.WriteAllText(desiredPath, SchemaYamlSerializer.ToYaml(desired)); + + Assert.Equal(0, RunCli(connectionString, originalPath, "all")); + Assert.Equal(0, RunCli(connectionString, desiredPath, "structural")); + var originalPredicate = ReadPolicy(connection, "owner_policy"); + var originalScope = ReadPolicy(connection, "scope_policy"); + Assert.Contains( + "IS NOT NULL", + originalPredicate.Using, + StringComparison.OrdinalIgnoreCase + ); + Assert.Equal("SELECT", originalScope.Command); + + Assert.Equal(0, RunCli(connectionString, desiredPath, "rls")); + var changedPredicate = ReadPolicy(connection, "owner_policy"); + var changedScope = ReadPolicy(connection, "scope_policy"); + Assert.Contains("IS NULL", changedPredicate.Using, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "IS NULL", + changedPredicate.WithCheck, + StringComparison.OrdinalIgnoreCase + ); + Assert.DoesNotContain( + "IS NOT NULL", + changedPredicate.Using, + StringComparison.OrdinalIgnoreCase + ); + Assert.Equal("DELETE", changedScope.Command); + + var live = Assert + .IsType(PostgresSchemaInspector.Inspect(connection, "public")) + .Value; + Assert.Empty( + Assert.IsType(SchemaDiff.Calculate(live, desired)).Value + ); + Assert.Equal(0, RunCli(connectionString, desiredPath, "rls")); + Assert.Equal(changedPredicate, ReadPolicy(connection, "owner_policy")); + Assert.Equal(changedScope, ReadPolicy(connection, "scope_policy")); + } + finally + { + File.Delete(originalPath); + File.Delete(desiredPath); + } + } + + private static int RunCli(string connectionString, string schemaPath, string phase) => + MigrationCliConsole + .Migrate( + schemaPath: schemaPath, + provider: "postgres", + output: connectionString, + phase: phase + ) + .ExitCode; + + private static (string Command, string? Using, string? WithCheck) ReadPolicy( + NpgsqlConnection connection, + string name + ) + { + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT cmd, qual, with_check FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'documents' AND policyname = @name + """; + command.Parameters.AddWithValue("name", name); + using var reader = command.ExecuteReader(); + Assert.True(reader.Read(), $"Missing policy {name}"); + var policy = ( + Command: reader.GetString(0), + Using: reader.IsDBNull(1) ? null : reader.GetString(1), + WithCheck: reader.IsDBNull(2) ? null : reader.GetString(2) + ); + Assert.False(reader.Read(), $"Duplicate policy {name}"); + return policy; + } + + private static SchemaDefinition PolicySchema(bool changed) => + new() + { + Name = "rls_phase_upgrade", + Tables = + [ + new TableDefinition + { + Name = "documents", + Columns = + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + new ColumnDefinition { Name = "owner_id", Type = PortableTypes.Uuid }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + RowLevelSecurity = new RlsPolicySetDefinition + { + Policies = + [ + new RlsPolicyDefinition + { + Name = "owner_policy", + Operations = [RlsOperation.All], + UsingSql = changed ? "owner_id IS NULL" : "owner_id IS NOT NULL", + WithCheckSql = changed + ? "owner_id IS NULL" + : "owner_id IS NOT NULL", + }, + new RlsPolicyDefinition + { + Name = "scope_policy", + Operations = [changed ? RlsOperation.Delete : RlsOperation.Select], + UsingSql = "TRUE", + }, + ], + }, + }, + ], + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportE2ETests.cs index 9a49f08a..fde72554 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportE2ETests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportE2ETests.cs @@ -61,16 +61,10 @@ private void Apply(SchemaDefinition schema) var ops = ( (OperationsResultOk)SchemaDiff.Calculate(current, schema, logger: _logger) ).Value; - var apply = MigrationRunner.Apply( - _connection, - ops, - PostgresDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); - Assert.True( - apply is MigrationApplyResultOk, - $"Migration failed: {(apply as MigrationApplyResultError)?.Value}" + PostgresLqlOnlyE2ETests.ApplyOperations( + connection: _connection, + operations: ops, + logger: _logger ); } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresTestDb.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresTestDb.cs new file mode 100644 index 00000000..0063d3cb --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresTestDb.cs @@ -0,0 +1,105 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +/// +/// Shared PostgreSQL E2E test helper: applies a desired schema via +/// inspect → diff → apply and asserts the migration succeeded. +/// +internal static class PostgresTestDb +{ + private const string SchemaName = "public"; + + public static void ApplySchema( + NpgsqlConnection connection, + SchemaDefinition desired, + ILogger logger, + bool allowDestructive = false + ) + { + var current = Assert + .IsType(PostgresSchemaInspector.Inspect(connection, "public", logger)) + .Value; + var ops = Assert + .IsType( + SchemaDiff.Calculate(current, desired, allowDestructive, logger: logger) + ) + .Value; + + var apply = MigrationRunner.Apply( + connection, + ops, + PostgresDdlGenerator.Generate, + allowDestructive ? MigrationOptions.Destructive : MigrationOptions.Default, + logger + ); + var error = apply is MigrationApplyResultError failure ? failure.Value.Message : null; + Assert.True(apply is MigrationApplyResultOk, $"Migration failed: {error}"); + } + + /// Executes a non-query SQL statement on the connection. + internal static void Exec(NpgsqlConnection conn, string sql) + { + using var command = conn.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// Executes a non-query SQL statement within the given transaction. + internal static void Exec(NpgsqlConnection conn, NpgsqlTransaction tx, string sql) + { + using var command = conn.CreateCommand(); + command.Transaction = tx; + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// Inspects the live PostgreSQL schema, asserting success. + internal static SchemaDefinition Inspect(NpgsqlConnection connection, ILogger logger) + { + var result = PostgresSchemaInspector.Inspect(connection, SchemaName, logger); + if (result is SchemaResultOk ok) + { + return ok.Value; + } + + Assert.Fail("Expected PostgreSQL schema inspection to succeed."); + return new SchemaDefinition { Name = "failed", Tables = [] }; + } + + /// Calculates the schema diff between current and desired, asserting success. + internal static IReadOnlyList Calculate( + SchemaDefinition current, + SchemaDefinition desired, + ILogger logger, + bool allowDestructive = false + ) + { + var result = SchemaDiff.Calculate(current, desired, allowDestructive, logger); + if (result is OperationsResultOk ok) + { + return ok.Value; + } + + Assert.Fail("Expected PostgreSQL schema diff to succeed."); + return []; + } + + /// Applies the operations against PostgreSQL, asserting the migration succeeded. + internal static void Apply( + NpgsqlConnection connection, + IReadOnlyList operations, + ILogger logger, + MigrationOptions? options = null + ) + { + var result = MigrationRunner.Apply( + connection, + operations, + PostgresDdlGenerator.Generate, + options ?? MigrationOptions.Default, + logger + ); + var failure = result is MigrationApplyResultError error ? error.Value.ToString() : ""; + + Assert.True(result is MigrationApplyResultOk, $"Migration failed: {failure}"); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresTriggerDdlTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresTriggerDdlTests.cs new file mode 100644 index 00000000..7d7d1aff --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresTriggerDdlTests.cs @@ -0,0 +1,73 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [MIG-TRIGGER-PG] DDL emission rules from +// docs/specs/declarative-triggers-spec.md (GitHub issue 82). + +/// +/// Generator-level tests for PostgreSQL trigger guard DDL emission rules +/// that do not need a live database. +/// +public sealed class PostgresTriggerDdlTests +{ + [Fact] + public void PgTriggerDdl_DollarTagCollision_PicksAlternateTag() + { + var ddl = PostgresDdlGenerator.Generate( + new CreateTriggerOperation( + "public", + "tenant_members", + Guard() with + { + ErrorMessage = "do not $trigger_guard$ terminate me", + } + ) + ); + + Assert.Contains("$trigger_guard1$", ddl, StringComparison.Ordinal); + Assert.Contains("do not $trigger_guard$ terminate me", ddl, StringComparison.Ordinal); + } + + [Fact] + public void PgTriggerDdl_ManagedPrefix_AppliedToTriggerObjectName() + { + var ddl = PostgresDdlGenerator.Generate( + new CreateTriggerOperation("public", "tenant_members", Guard()) + ); + + Assert.Contains( + "CREATE TRIGGER \"usr_assert_not_last_owner\"", + ddl, + StringComparison.Ordinal + ); + } + + [Fact] + public void PgTriggerDdl_IdentifierOver63Bytes_FailsLoudly() + { + // PostgreSQL silently truncates identifiers to 63 bytes, which would + // desynchronise create/drop/inspect names. + var ex = Assert.Throws(() => + PostgresDdlGenerator.Generate( + new CreateTriggerOperation( + "public", + "tenant_members", + Guard() with + { + Name = new string('x', 80), + } + ) + ) + ); + + Assert.Contains("63-byte", ex.Message, StringComparison.Ordinal); + } + + private static TriggerDefinition Guard() => + new() + { + Name = "assert_not_last_owner", + Events = [TriggerEvent.Delete], + RaiseWhenLql = "old.role = 'owner'", + ErrorMessage = "cannot remove the last owner of a tenant", + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresUniqueConstraintIssue55E2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresUniqueConstraintIssue55E2ETests.cs index 6f5c1cde..4272d627 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresUniqueConstraintIssue55E2ETests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresUniqueConstraintIssue55E2ETests.cs @@ -29,13 +29,25 @@ public async Task Calculate_AddingCompositeUniqueOnExistingTableWithFk_EmitsAddU try { - Apply(connection, Calculate(Inspect(connection), SchemaWithoutUnique())); + PostgresTestDb.Apply( + connection, + PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithoutUnique(), + Logger + ), + Logger + ); - var live = Inspect(connection); + var live = PostgresTestDb.Inspect(connection, Logger); var agentConfigs = live.Tables.Single(t => t.Name == "agent_configs"); Assert.Empty(agentConfigs.UniqueConstraints); - var upgrade = Calculate(Inspect(connection), SchemaWithUnique()); + var upgrade = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithUnique(), + Logger + ); Assert.Contains( upgrade, @@ -45,9 +57,11 @@ op is AddUniqueConstraintOperation add && add.UniqueConstraint.Columns.SequenceEqual(["tenant_id", "name"]) ); - Apply(connection, upgrade); + PostgresTestDb.Apply(connection, upgrade, Logger); - var converged = Inspect(connection).Tables.Single(t => t.Name == "agent_configs"); + var converged = PostgresTestDb + .Inspect(connection, Logger) + .Tables.Single(t => t.Name == "agent_configs"); Assert.Contains( converged.UniqueConstraints, c => @@ -55,7 +69,11 @@ op is AddUniqueConstraintOperation add && c.Columns.SequenceEqual(["tenant_id", "name"]) ); - var replay = Calculate(Inspect(connection), SchemaWithUnique()); + var replay = PostgresTestDb.Calculate( + PostgresTestDb.Inspect(connection, Logger), + SchemaWithUnique(), + Logger + ); Assert.DoesNotContain(replay, op => op is AddUniqueConstraintOperation); } finally @@ -136,50 +154,4 @@ private static SchemaDefinition Schema(bool includeUnique) }; return new SchemaDefinition { Name = "issue55", Tables = [tenants, agentConfigs] }; } - - private static SchemaDefinition Inspect(NpgsqlConnection connection) - { - var result = PostgresSchemaInspector.Inspect(connection, SchemaName, Logger); - if (result is SchemaResultOk ok) - { - return ok.Value; - } - Assert.Fail("Expected PostgreSQL schema inspection to succeed."); - return new SchemaDefinition { Name = "failed", Tables = [] }; - } - - private static IReadOnlyList Calculate( - SchemaDefinition current, - SchemaDefinition desired - ) - { - var result = SchemaDiff.Calculate( - current: current, - desired: desired, - allowDestructive: false, - logger: Logger - ); - if (result is OperationsResultOk ok) - { - return ok.Value; - } - Assert.Fail("Expected PostgreSQL schema diff to succeed."); - return []; - } - - private static void Apply( - NpgsqlConnection connection, - IReadOnlyList operations - ) - { - var result = MigrationRunner.Apply( - connection: connection, - operations: operations, - generateDdl: PostgresDdlGenerator.Generate, - options: MigrationOptions.Default, - logger: Logger - ); - var failure = result is MigrationApplyResultError error ? error.Value.ToString() : ""; - Assert.True(result is MigrationApplyResultOk, $"Migration failed: {failure}"); - } } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresUpgradeBlockersTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresUpgradeBlockersTests.cs new file mode 100644 index 00000000..6c49dc13 --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresUpgradeBlockersTests.cs @@ -0,0 +1,296 @@ +using System.Collections.Immutable; +using SchemaIntegrityOk = Outcome.Result< + System.Collections.Immutable.ImmutableArray, + Nimblesite.DataProvider.Migration.Core.MigrationError +>.Ok< + System.Collections.Immutable.ImmutableArray, + Nimblesite.DataProvider.Migration.Core.MigrationError +>; + +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [MIG-EXISTING-DATABASE-UPGRADE] and [RLS-DIFF]. +[Collection(PostgresTestSuite.Name)] +public sealed class PostgresUpgradeBlockersTests(PostgresContainerFixture fixture) +{ + [Fact] + public void AdditiveUpgrade_RelaxesNotNullOnExistingTableAndRerunsCleanly() + { + using var connection = fixture.CreateDatabase("nullable_upgrade"); + Migrate(connection, NullableSchema(nullable: false)); + var desired = NullableSchema(nullable: true); + + Migrate(connection, desired); + + var notes = Inspect(connection).Tables.Single().Columns.Single(c => c.Name == "notes"); + Assert.True(notes.IsNullable); + AssertNoDrift(connection, desired); + Assert.Empty(Migrate(connection, desired)); + } + + [Fact] + public void AdditiveUpgrade_AddsUnnamedForeignKeyAlongsideNewColumn() + { + using var connection = fixture.CreateDatabase("foreign_key_upgrade"); + Migrate(connection, ForeignKeySchema(hasReferenceColumn: false)); + var desired = ForeignKeySchema(hasReferenceColumn: true); + + Migrate(connection, desired); + + Assert.Single(Inspect(connection).Tables.Single(t => t.Name == "children").ForeignKeys); + AssertNoDrift(connection, desired); + Assert.Empty(Migrate(connection, desired)); + } + + [Fact] + public void AdditiveUpgrade_RepairsMissingForeignKeyAfterColumnWasApplied() + { + using var connection = fixture.CreateDatabase("partial_foreign_key_upgrade"); + Migrate(connection, ForeignKeySchema(hasReferenceColumn: false)); + var desired = ForeignKeySchema(hasReferenceColumn: true); + var addedColumn = Assert.Single( + Diff(Inspect(connection), desired).OfType() + ); + Apply(connection, [addedColumn]); + + Migrate(connection, desired); + + Assert.Single(Inspect(connection).Tables.Single(t => t.Name == "children").ForeignKeys); + AssertNoDrift(connection, desired); + Assert.Empty(Migrate(connection, desired)); + } + + [Fact] + public void AdditiveUpgrade_ReplacesChangedUsingAndWithCheckPolicyPredicates() + { + using var connection = fixture.CreateDatabase("policy_upgrade"); + Migrate(connection, PolicySchema("IS NOT NULL")); + var desired = PolicySchema("IS NULL"); + var upgrade = Diff(Inspect(connection), desired); + + Assert.NotEmpty(upgrade); + Assert.Contains(VerifyMismatches(connection, desired), m => m.Contains("policy")); + Apply(connection, upgrade); + + var policy = Inspect(connection).Tables.Single().RowLevelSecurity?.Policies.Single(); + Assert.Contains("IS NULL", policy?.UsingSql); + Assert.Contains("IS NULL", policy?.WithCheckSql); + AssertNoDrift(connection, desired); + Assert.Empty(Migrate(connection, desired)); + } + + [Fact] + public void DestructiveCleanup_DropsOnlyObsoleteForeignKeyAndRerunsCleanly() + { + using var connection = fixture.CreateDatabase("foreign_key_cleanup"); + Migrate(connection, CleanupSchema(includeObsolete: true)); + var desired = CleanupSchema(includeObsolete: false); + var cleanup = Diff(Inspect(connection), desired, destructive: true); + + var drop = Assert.Single(cleanup.OfType()); + Assert.Equal("FK_children_obsolete_id", drop.ConstraintName); + Apply(connection, cleanup, destructive: true); + + var foreignKeys = Inspect(connection).Tables.Single(t => t.Name == "children").ForeignKeys; + Assert.Equal(2, foreignKeys.Count); + Assert.Contains(foreignKeys, fk => fk.Columns.Single() == "kept_a_id"); + Assert.Contains(foreignKeys, fk => fk.Columns.Single() == "kept_b_id"); + AssertNoDrift(connection, desired); + Assert.Empty(Migrate(connection, desired, destructive: true)); + } + + private static SchemaDefinition NullableSchema(bool nullable) => + new() + { + Name = "nullable_upgrade", + Tables = + [ + new TableDefinition + { + Name = "documents", + Columns = + [ + Id(), + new ColumnDefinition + { + Name = "notes", + Type = PortableTypes.Text, + IsNullable = nullable, + }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + }, + ], + }; + + private static SchemaDefinition ForeignKeySchema(bool hasReferenceColumn) => + new() + { + Name = "foreign_key_upgrade", + Tables = + [ + Parent("parents"), + new TableDefinition + { + Name = "children", + Columns = hasReferenceColumn ? [Id(), UuidColumn("parent_id")] : [Id()], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + ForeignKeys = hasReferenceColumn ? [ForeignKey("parent_id", "parents")] : [], + }, + ], + }; + + private static SchemaDefinition PolicySchema(string predicate) => + new() + { + Name = "policy_upgrade", + Tables = + [ + new TableDefinition + { + Name = "documents", + Columns = [Id(), UuidColumn("owner_id")], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + RowLevelSecurity = new RlsPolicySetDefinition + { + Policies = + [ + new RlsPolicyDefinition + { + Name = "owner_policy", + UsingSql = $"owner_id {predicate}", + WithCheckSql = $"owner_id {predicate}", + }, + ], + }, + }, + ], + }; + + private static SchemaDefinition CleanupSchema(bool includeObsolete) + { + ForeignKeyDefinition[] kept = + [ + ForeignKey("kept_a_id", "kept_a"), + ForeignKey("kept_b_id", "kept_b") with + { + Name = "FK_children_kept_b_id", + }, + ]; + var foreignKeys = includeObsolete + ? kept.Append( + ForeignKey("obsolete_id", "obsolete") with + { + Name = "FK_children_obsolete_id", + } + ) + .ToArray() + : kept; + return new SchemaDefinition + { + Name = "foreign_key_cleanup", + Tables = + [ + Parent("kept_a"), + Parent("kept_b"), + Parent("obsolete"), + new TableDefinition + { + Name = "children", + Columns = + [ + Id(), + UuidColumn("kept_a_id"), + UuidColumn("kept_b_id"), + UuidColumn("obsolete_id"), + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + ForeignKeys = foreignKeys, + }, + ], + }; + } + + private static TableDefinition Parent(string name) => + new() + { + Name = name, + Columns = [Id()], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + }; + + private static ColumnDefinition Id() => + new() + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }; + + private static ColumnDefinition UuidColumn(string name) => + new() { Name = name, Type = PortableTypes.Uuid }; + + private static ForeignKeyDefinition ForeignKey(string column, string table) => + new() + { + Columns = [column], + ReferencedTable = table, + ReferencedColumns = ["id"], + }; + + private static SchemaDefinition Inspect(NpgsqlConnection connection) => + Assert + .IsType( + PostgresSchemaInspector.Inspect(connection, "public", NullLogger.Instance) + ) + .Value; + + private static IReadOnlyList Diff( + SchemaDefinition current, + SchemaDefinition desired, + bool destructive = false + ) => + Assert + .IsType( + SchemaDiff.Calculate(current, desired, allowDestructive: destructive) + ) + .Value; + + private static IReadOnlyList Migrate( + NpgsqlConnection connection, + SchemaDefinition desired, + bool destructive = false + ) + { + var operations = Diff(Inspect(connection), desired, destructive); + Apply(connection, operations, destructive); + return operations; + } + + private static void Apply( + NpgsqlConnection connection, + IReadOnlyList operations, + bool destructive = false + ) + { + var result = MigrationRunner.Apply( + connection, + operations, + PostgresDdlGenerator.Generate, + destructive ? MigrationOptions.Destructive : MigrationOptions.Default + ); + var failure = result is MigrationApplyResultError error ? error.Value.ToString() : ""; + Assert.True(result is MigrationApplyResultOk, $"Migration failed: {failure}"); + } + + private static ImmutableArray VerifyMismatches( + NpgsqlConnection connection, + SchemaDefinition desired + ) => + Assert + .IsType(SchemaIntegrityVerifier.Verify(Inspect(connection), desired)) + .Value; + + private static void AssertNoDrift(NpgsqlConnection connection, SchemaDefinition desired) => + Assert.Empty(VerifyMismatches(connection, desired)); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/RlsPolicyFunctionArgumentDriftTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/RlsPolicyFunctionArgumentDriftTests.cs new file mode 100644 index 00000000..b08a9d7b --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/RlsPolicyFunctionArgumentDriftTests.cs @@ -0,0 +1,50 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +public sealed class RlsPolicyFunctionArgumentDriftTests +{ + [Fact] + public void QualifiedFunctionName_DoesNotHideChangedStringArgument() + { + var current = PolicySchema("public.is_member('public.is_member')"); + var desired = PolicySchema("is_member('is_member')"); + + var operations = ((OperationsResultOk)SchemaDiff.Calculate(current, desired)).Value; + + Assert.Contains(operations, operation => operation is AlterRlsPolicyOperation); + } + + private static SchemaDefinition PolicySchema(string predicate) => + new() + { + Name = "rls_function_argument_drift", + Tables = + [ + new TableDefinition + { + Name = "documents", + Columns = + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + RowLevelSecurity = new RlsPolicySetDefinition + { + Policies = + [ + new RlsPolicyDefinition + { + Name = "documents_member", + Operations = [RlsOperation.Select], + UsingSql = predicate, + }, + ], + }, + }, + ], + }; +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffCheckConstraintIssue57Tests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffCheckConstraintIssue57Tests.cs index 6c74a497..7bdff205 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffCheckConstraintIssue57Tests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffCheckConstraintIssue57Tests.cs @@ -18,26 +18,8 @@ public void Calculate_TableCheckConstraintExpressionChanged_DropsAndReadds() Name = "live", Tables = [ - new TableDefinition + BaseUsageEventsTable() with { - Schema = "public", - Name = "usage_events", - Columns = - [ - new ColumnDefinition - { - Name = "id", - Type = PortableTypes.Uuid, - IsNullable = false, - }, - new ColumnDefinition - { - Name = "kind", - Type = PortableTypes.Text, - IsNullable = false, - }, - ], - PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, CheckConstraints = [ new CheckConstraintDefinition @@ -102,28 +84,17 @@ public void Calculate_ColumnCheckConstraintExpressionChanged_DropsAndReadds() Name = "live", Tables = [ - new TableDefinition + BaseUsageEventsTable() with { - Schema = "public", - Name = "usage_events", Columns = [ - new ColumnDefinition + BaseUsageEventsTable().Columns[0], + BaseUsageEventsTable().Columns[1] with { - Name = "id", - Type = PortableTypes.Uuid, - IsNullable = false, - }, - new ColumnDefinition - { - Name = "kind", - Type = PortableTypes.Text, - IsNullable = false, CheckConstraint = "kind IN ('request','input_tokens','output_tokens','sandbox_seconds')", }, ], - PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, }, ], }; @@ -172,26 +143,8 @@ public void Calculate_CheckConstraintUnchanged_EmitsNoCheckConstraintOps() Name = "stable", Tables = [ - new TableDefinition + BaseUsageEventsTable() with { - Schema = "public", - Name = "usage_events", - Columns = - [ - new ColumnDefinition - { - Name = "id", - Type = PortableTypes.Uuid, - IsNullable = false, - }, - new ColumnDefinition - { - Name = "kind", - Type = PortableTypes.Text, - IsNullable = false, - }, - ], - PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, CheckConstraints = [ new CheckConstraintDefinition @@ -216,4 +169,27 @@ public void Calculate_CheckConstraintUnchanged_EmitsNoCheckConstraintOps() Assert.DoesNotContain(ops, op => op is DropCheckConstraintOperation); Assert.DoesNotContain(ops, op => op is AddCheckConstraintOperation); } + + private static TableDefinition BaseUsageEventsTable() => + new() + { + Schema = "public", + Name = "usage_events", + Columns = + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + new ColumnDefinition + { + Name = "kind", + Type = PortableTypes.Text, + IsNullable = false, + }, + ], + PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, + }; } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffRlsTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffRlsTests.cs index 2473483e..10cdb60d 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffRlsTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffRlsTests.cs @@ -7,7 +7,10 @@ namespace Nimblesite.DataProvider.Migration.Tests; /// public sealed class SchemaDiffRlsTests { - private static SchemaDefinition WithRls(RlsPolicySetDefinition? rls) => + private static SchemaDefinition WithRls( + RlsPolicySetDefinition? rls, + string schema = "public" + ) => new() { Name = "t", @@ -15,7 +18,7 @@ private static SchemaDefinition WithRls(RlsPolicySetDefinition? rls) => [ new TableDefinition { - Schema = "public", + Schema = schema, Name = "Documents", Columns = [new ColumnDefinition { Name = "Id", Type = new UuidType() }], PrimaryKey = new PrimaryKeyDefinition { Columns = ["Id"] }, @@ -24,6 +27,26 @@ private static SchemaDefinition WithRls(RlsPolicySetDefinition? rls) => ], }; + // Implements [RLS-DIFF]: unknown live predicates must not bypass security reconciliation. + [Fact] + public void Diff_SqliteUnreadablePolicy_DoesNotSilentlyPreserveUnknownPredicates() + { + var policy = new RlsPolicyDefinition { Name = "owner" }; + var denied = policy with { UsingSql = "false", WithCheckSql = "false" }; + var current = WithRls( + rls: new RlsPolicySetDefinition { Policies = [policy] }, + schema: "main" + ); + var desired = WithRls( + rls: new RlsPolicySetDefinition { Policies = [denied] }, + schema: "main" + ); + var ops = Assert + .IsType(SchemaDiff.Calculate(current: current, desired: desired)) + .Value; + Assert.IsType(Assert.Single(ops)); + } + [Fact] public void Diff_NewTableWithRls_EmitsCreateTableThenEnableThenPolicy() { diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffTests.cs index 1234e46d..3d7a6d8b 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffTests.cs @@ -65,10 +65,7 @@ public void Calculate_SameSchema_NoOperations() public void Calculate_NewColumn_AddsColumn() { // Arrange - var current = Schema - .Define("Current") - .Table("public", "users", t => t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())) - .Build(); + var current = UsersIdOnly("Current"); var desired = Schema .Define("Desired") @@ -199,10 +196,7 @@ public void Calculate_RemovedTable_NotDroppedByDefault() ) .Build(); - var desired = Schema - .Define("Desired") - .Table("public", "users", t => t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())) - .Build(); + var desired = UsersIdOnly("Desired"); // Act var result = SchemaDiff.Calculate(current, desired, allowDestructive: false); @@ -228,10 +222,7 @@ public void Calculate_RemovedTable_DroppedWhenDestructiveAllowed() ) .Build(); - var desired = Schema - .Define("Desired") - .Table("public", "users", t => t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())) - .Build(); + var desired = UsersIdOnly("Desired"); // Act var result = SchemaDiff.Calculate(current, desired, allowDestructive: true); @@ -261,10 +252,7 @@ public void Calculate_RemovedColumn_NotDroppedByDefault() ) .Build(); - var desired = Schema - .Define("Desired") - .Table("public", "users", t => t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())) - .Build(); + var desired = UsersIdOnly("Desired"); // Act var result = SchemaDiff.Calculate(current, desired, allowDestructive: false); @@ -291,10 +279,7 @@ public void Calculate_RemovedColumn_DroppedWhenDestructiveAllowed() ) .Build(); - var desired = Schema - .Define("Desired") - .Table("public", "users", t => t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())) - .Build(); + var desired = UsersIdOnly("Desired"); // Act var result = SchemaDiff.Calculate(current, desired, allowDestructive: true); @@ -695,4 +680,10 @@ private static TableDefinition RlsTable(RlsPolicySetDefinition rls) => PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, RowLevelSecurity = rls, }; + + private static SchemaDefinition UsersIdOnly(string name) => + Schema + .Define(name) + .Table("public", "users", t => t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())) + .Build(); } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffUniqueConstraintIssue55Tests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffUniqueConstraintIssue55Tests.cs index d6e16daf..decfd18e 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffUniqueConstraintIssue55Tests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffUniqueConstraintIssue55Tests.cs @@ -8,6 +8,28 @@ namespace Nimblesite.DataProvider.Migration.Tests; /// public sealed class SchemaDiffUniqueConstraintIssue55Tests { + private static IReadOnlyList AgentConfigColumns() => + [ + new ColumnDefinition + { + Name = "id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + new ColumnDefinition + { + Name = "tenant_id", + Type = PortableTypes.Uuid, + IsNullable = false, + }, + new ColumnDefinition + { + Name = "name", + Type = PortableTypes.Text, + IsNullable = false, + }, + ]; + [Fact] public void Calculate_AddingCompositeUniqueConstraintToExistingTable_YieldsAddOperation() { @@ -20,27 +42,7 @@ public void Calculate_AddingCompositeUniqueConstraintToExistingTable_YieldsAddOp { Schema = "public", Name = "agent_configs", - Columns = - [ - new ColumnDefinition - { - Name = "id", - Type = PortableTypes.Uuid, - IsNullable = false, - }, - new ColumnDefinition - { - Name = "tenant_id", - Type = PortableTypes.Uuid, - IsNullable = false, - }, - new ColumnDefinition - { - Name = "name", - Type = PortableTypes.Text, - IsNullable = false, - }, - ], + Columns = AgentConfigColumns(), PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, }, ], @@ -103,27 +105,7 @@ public void Calculate_ReplayAfterUniqueConstraintApplied_YieldsNoUniqueOperation { Schema = "public", Name = "agent_configs", - Columns = - [ - new ColumnDefinition - { - Name = "id", - Type = PortableTypes.Uuid, - IsNullable = false, - }, - new ColumnDefinition - { - Name = "tenant_id", - Type = PortableTypes.Uuid, - IsNullable = false, - }, - new ColumnDefinition - { - Name = "name", - Type = PortableTypes.Text, - IsNullable = false, - }, - ], + Columns = AgentConfigColumns(), PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] }, UniqueConstraints = [unique], }, diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierFunctionDriftTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierFunctionDriftTests.cs index 1751ae24..c14cf12a 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierFunctionDriftTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierFunctionDriftTests.cs @@ -70,6 +70,8 @@ public void Verify_WhenFunctionDefinitionDrifts_ReturnsDetailedMismatches() mismatches ); Assert.Contains("public.current_tenant_id(text): function body drifted", mismatches); + Assert.Equal(5, mismatches.Length); + Assert.Empty(Verify(live: desired, desired: desired)); } private static ImmutableArray Verify(SchemaDefinition live, SchemaDefinition desired) diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierIssue59Tests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierIssue59Tests.cs index a1844904..b7ed7648 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierIssue59Tests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaIntegrityVerifierIssue59Tests.cs @@ -74,6 +74,35 @@ public void Verify_TextDefaultStoredWithTypeCast_TreatedAsEqualToBareLiteral() var mismatches = Verify(live: live, desired: desired); Assert.Empty(mismatches); + + var changedDefault = new SchemaDefinition + { + Name = "changed-default", + Tables = + [ + new TableDefinition + { + Schema = "public", + Name = "t", + Columns = + [ + new ColumnDefinition + { + Name = "status", + Type = PortableTypes.Text, + IsNullable = false, + DefaultValue = "'failed'", + }, + ], + }, + ], + }; + + var changedDefaultMismatches = Verify(live: live, desired: changedDefault); + + Assert.Single(changedDefaultMismatches); + Assert.Contains("public.t.status: default expected 'failed'", changedDefaultMismatches[0]); + Assert.DoesNotContain("default expected 'pending'", changedDefaultMismatches[0]); } [Fact] @@ -118,6 +147,18 @@ public void Verify_MultiRoleGrantInDesired_MatchedAgainstSingleRoleGrantsFromIns var mismatches = Verify(live: live, desired: desired); Assert.Empty(mismatches); + + var missingAdminGrant = new SchemaDefinition + { + Name = "missing-admin-grant", + Grants = [live.Grants[0]], + }; + + var missingAdminMismatches = Verify(live: missingAdminGrant, desired: desired); + + Assert.Single(missingAdminMismatches); + Assert.Contains("grant Schema", missingAdminMismatches[0]); + Assert.Contains("missing grant", missingAdminMismatches[0]); } [Fact] @@ -174,6 +215,19 @@ public void Verify_AllTablesInSchemaTarget_MatchedAgainstPerTableGrantsFromInspe var mismatches = Verify(live: live, desired: desired); Assert.Empty(mismatches); + + var missingTableGrant = new SchemaDefinition + { + Name = "missing-table-grant", + Tables = live.Tables, + Grants = [live.Grants[0]], + }; + + var missingTableMismatches = Verify(live: missingTableGrant, desired: desired); + + Assert.Single(missingTableMismatches); + Assert.Contains("grant AllTablesInSchema", missingTableMismatches[0]); + Assert.Contains("missing grant", missingTableMismatches[0]); } private static ImmutableArray Verify(SchemaDefinition live, SchemaDefinition desired) diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaVerifier.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaVerifier.cs index ea943d70..179b0c8c 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaVerifier.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaVerifier.cs @@ -529,7 +529,7 @@ public static void VerifyDefaultsWorkAtRuntime( // HELPER METHODS // ========================================================================= - private static bool PostgresTableExists(NpgsqlConnection conn, string tableName, string schema) + internal static bool PostgresTableExists(NpgsqlConnection conn, string tableName, string schema) { using var cmd = conn.CreateCommand(); cmd.CommandText = """ @@ -584,7 +584,7 @@ SELECT column_name FROM information_schema.columns return columns; } - private static bool SqliteTableExists(SqliteConnection conn, string tableName) + internal static bool SqliteTableExists(SqliteConnection conn, string tableName) { using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@name"; diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteDeclarativeTriggerTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteDeclarativeTriggerTests.cs new file mode 100644 index 00000000..f03a82bc --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteDeclarativeTriggerTests.cs @@ -0,0 +1,322 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [MIG-TRIGGER-SQLITE] and [MIG-TRIGGER-DIFF] from +// docs/specs/declarative-triggers-spec.md (GitHub issue 82). + +/// +/// E2E tests for declarative BEFORE-trigger guards on SQLite. The schema +/// declares a last-owner guard on TenantMembers: demoting or deleting the +/// only 'owner' row of a tenant must fail with the declared error message. +/// +public sealed class SqliteDeclarativeTriggerTests +{ + private static readonly ILogger Logger = NullLogger.Instance; + private static readonly string OwnerA = Guid.NewGuid().ToString(); + private static readonly string OwnerB = Guid.NewGuid().ToString(); + private static readonly string MemberC = Guid.NewGuid().ToString(); + private static readonly string Tenant1 = Guid.NewGuid().ToString(); + private static readonly string Tenant2 = Guid.NewGuid().ToString(); + + private const string GuardYaml = """ + name: tenant_app + tables: + - name: TenantMembers + schema: main + columns: + - name: Id + type: Text + isNullable: false + - name: TenantId + type: Text + isNullable: false + - name: Role + type: Text + isNullable: false + primaryKey: + columns: + - Id + triggers: + - name: assert_not_last_owner + timing: Before + events: [Update, Delete] + forEachRow: true + raiseWhen: | + old.Role = 'owner' and not exists( + TenantMembers + |> filter(fn(m) => m.TenantId = old.TenantId and m.Role = 'owner' and m.Id <> old.Id) + ) + errorMessage: cannot remove the last owner of a tenant + """; + + private const string PlainYaml = """ + name: tenant_app + tables: + - name: TenantMembers + schema: main + columns: + - name: Id + type: Text + isNullable: false + - name: TenantId + type: Text + isNullable: false + - name: Role + type: Text + isNullable: false + primaryKey: + columns: + - Id + """; + + [Fact] + public void Sqlite_TriggerGuard_BlocksDeleteOfLastOwner() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + InsertMember(connection, OwnerA, Tenant1, "owner"); + InsertMember(connection, MemberC, Tenant1, "member"); + + var ex = Assert.Throws(() => DeleteMember(connection, OwnerA)); + + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + Assert.Equal(2, SqliteTestDb.CountRows(connection, "TenantMembers")); + }); + } + + [Fact] + public void Sqlite_TriggerGuard_AllowsDeleteWhenAnotherOwnerExists() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + InsertMember(connection, OwnerA, Tenant1, "owner"); + InsertMember(connection, OwnerB, Tenant1, "owner"); + + DeleteMember(connection, OwnerA); + + Assert.Equal(1, SqliteTestDb.CountRows(connection, "TenantMembers")); + }); + } + + [Fact] + public void Sqlite_TriggerGuard_BlocksDemoteOfLastOwner() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + InsertMember(connection, OwnerA, Tenant1, "owner"); + InsertMember(connection, MemberC, Tenant1, "member"); + + var ex = Assert.Throws(() => DemoteMember(connection, OwnerA)); + + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + }); + } + + [Fact] + public void Sqlite_TriggerGuard_AllowsDemoteWhenAnotherOwnerExists() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + InsertMember(connection, OwnerA, Tenant1, "owner"); + InsertMember(connection, OwnerB, Tenant1, "owner"); + + DemoteMember(connection, OwnerA); + + Assert.Equal( + 1, + SqliteTestDb.Count( + connection, + "SELECT COUNT(*) FROM [TenantMembers] WHERE [Role]='owner'" + ) + ); + }); + } + + [Fact] + public void Sqlite_TriggerGuard_IsScopedToTenant() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + InsertMember(connection, OwnerA, Tenant1, "owner"); + InsertMember(connection, OwnerB, Tenant2, "owner"); + + // OwnerB owns a different tenant, so OwnerA is still the last + // owner of Tenant1 and must not be deletable. + var ex = Assert.Throws(() => DeleteMember(connection, OwnerA)); + + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + }); + } + + [Fact] + public void Sqlite_Trigger_RediffAfterApply_ProducesNoOperations() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + + var current = SqliteTestDb.Inspect(connection); + var ops = ( + (OperationsResultOk) + SchemaDiff.Calculate( + current, + SchemaYamlSerializer.FromYaml(GuardYaml), + logger: Logger + ) + ).Value; + + Assert.Empty(ops); + }); + } + + [Fact] + public void Sqlite_TriggerRemoval_WithoutDestructive_KeepsGuard() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + SqliteTestDb.ApplySchema(connection, SchemaYamlSerializer.FromYaml(PlainYaml)); + InsertMember(connection, OwnerA, Tenant1, "owner"); + + var ex = Assert.Throws(() => DeleteMember(connection, OwnerA)); + + Assert.Contains("last owner", ex.Message, StringComparison.Ordinal); + }); + } + + [Fact] + public void Sqlite_TriggerRemoval_WithDestructive_DropsGuard() + { + SqliteTestDb.WithDb(connection => + { + ApplyGuardSchema(connection); + SqliteTestDb.ApplySchema( + connection, + SchemaYamlSerializer.FromYaml(PlainYaml), + allowDestructive: true + ); + InsertMember(connection, OwnerA, Tenant1, "owner"); + + DeleteMember(connection, OwnerA); + + Assert.Equal(0, SqliteTestDb.CountRows(connection, "TenantMembers")); + }); + } + + [Fact] + public void Sqlite_TriggerGuard_OldReferenceOnInsertEvent_FailsLoudly() + { + // Implements [MIG-TRIGGER-GUARD-LQL]: old. does not exist on INSERT. + // Without validation SQLite fails at DML time while Postgres silently + // never fires the guard. + SqliteTestDb.WithDb(connection => + { + var result = TryApplyGuardVariant( + connection, + events: "[Insert]", + raiseWhen: "old.Role = 'owner'" + ); + + var error = Assert.IsType(result); + Assert.Contains("fires on Insert", error.Value.Message, StringComparison.Ordinal); + }); + } + + [Fact] + public void Sqlite_TriggerGuard_ForEachRowFalse_FailsLoudly() + { + // Implements [MIG-TRIGGER-GUARD-LQL]: guards are row-level on every + // platform; statement-level triggers see no OLD/NEW row on Postgres. + SqliteTestDb.WithDb(connection => + { + var result = TryApplyGuardVariant( + connection, + events: "[Delete]", + raiseWhen: "old.Role = 'owner'", + extraTriggerYaml: "forEachRow: false" + ); + + var error = Assert.IsType(result); + Assert.Contains("statement-level", error.Value.Message, StringComparison.Ordinal); + }); + } + + [Fact] + public void Sqlite_TriggerGuard_ReservedTokenInPredicate_FailsLoudly() + { + // Implements [MIG-TRIGGER-GUARD-LQL]: __TRG_ is reserved for internal + // sentinels; user literals containing it would be silently rewritten. + SqliteTestDb.WithDb(connection => + { + var result = TryApplyGuardVariant( + connection, + events: "[Delete]", + raiseWhen: "old.Role = '__TRG_OLD_Role__'" + ); + + var error = Assert.IsType(result); + Assert.Contains("reserved token", error.Value.Message, StringComparison.Ordinal); + }); + } + + private static MigrationApplyResult TryApplyGuardVariant( + SqliteConnection connection, + string events, + string raiseWhen, + string? extraTriggerYaml = null + ) + { + var extra = extraTriggerYaml is null ? string.Empty : $"\n {extraTriggerYaml}"; + var yaml = $""" + name: tenant_app + tables: + - name: TenantMembers + schema: main + columns: + - name: Id + type: Text + isNullable: false + - name: TenantId + type: Text + isNullable: false + - name: Role + type: Text + isNullable: false + primaryKey: + columns: + - Id + triggers: + - name: guard_variant + events: {events} + raiseWhen: "{raiseWhen}"{extra} + """; + return SqliteTestDb.TryApplySchema(connection, SchemaYamlSerializer.FromYaml(yaml)); + } + + private static void ApplyGuardSchema(SqliteConnection connection) => + SqliteTestDb.ApplySchema(connection, SchemaYamlSerializer.FromYaml(GuardYaml)); + + private static void InsertMember( + SqliteConnection connection, + string id, + string tenantId, + string role + ) => + SqliteTestDb.Execute( + connection, + $"INSERT INTO [TenantMembers]([Id], [TenantId], [Role]) VALUES ('{id}', '{tenantId}', '{role}')" + ); + + private static void DeleteMember(SqliteConnection connection, string id) => + SqliteTestDb.Execute(connection, $"DELETE FROM [TenantMembers] WHERE [Id]='{id}'"); + + private static void DemoteMember(SqliteConnection connection, string id) => + SqliteTestDb.Execute( + connection, + $"UPDATE [TenantMembers] SET [Role]='member' WHERE [Id]='{id}'" + ); +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteMigrationTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteMigrationTests.cs index ed903527..9b20b389 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteMigrationTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteMigrationTests.cs @@ -31,6 +31,23 @@ private static void CleanupTestDb(SqliteConnection connection, string dbPath) } } + private static void SeedSchema( + SqliteConnection connection, + SchemaDefinition schema, + ILogger logger + ) + { + var current = ((SchemaResultOk)SqliteSchemaInspector.Inspect(connection, logger)).Value; + var ops = ((OperationsResultOk)SchemaDiff.Calculate(current, schema, logger: logger)).Value; + _ = MigrationRunner.Apply( + connection, + ops, + SqliteDdlGenerator.Generate, + MigrationOptions.Default, + logger + ); + } + [Fact] public void CreateDatabaseFromScratch_SingleTable_Success() { @@ -182,19 +199,7 @@ public void UpgradeExistingDatabase_AddColumn_Success() .Build(); // Apply v1 - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var v1Ops = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - v1Ops, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, v1, _logger); // Define v2 with new columns var v2 = Schema @@ -260,19 +265,7 @@ public void UpgradeExistingDatabase_AddTable_Success() .Build(); // Apply v1 - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var v1Ops = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - v1Ops, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, v1, _logger); // v2 adds a new table var v2 = Schema @@ -341,19 +334,7 @@ public void UpgradeExistingDatabase_AddIndex_Success() .Build(); // Apply v1 - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var v1Ops = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - v1Ops, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, v1, _logger); // v2 adds an index var v2 = Schema @@ -541,19 +522,7 @@ public void Destructive_DropTable_BlockedByDefault() .Table("Products", t => t.Column("Id", PortableTypes.Uuid, c => c.PrimaryKey())) .Build(); - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var v1Ops = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - v1Ops, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, v1, _logger); // v2 removes Products table var v2 = Schema @@ -598,19 +567,7 @@ public void Destructive_DropTable_AllowedWithOption() .Table("Products", t => t.Column("Id", PortableTypes.Uuid, c => c.PrimaryKey())) .Build(); - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var v1Ops = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - v1Ops, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, v1, _logger); var v2 = Schema .Define("Test") @@ -674,19 +631,7 @@ public void SchemaInspector_RoundTrip_Matches() .Build(); // Create schema - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var operations = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - operations, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, schema, _logger); // Act - Inspect and compare var inspected = ( @@ -960,19 +905,7 @@ public void ExpressionIndex_EnforcesCaseInsensitiveUniqueness() ) .Build(); - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var operations = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, schema, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - operations, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, schema, _logger); // Act - Insert first venue using var insertCmd = connection.CreateCommand(); @@ -1179,19 +1112,7 @@ public void UpgradeIndex_ColumnToExpression_RequiresDropAndCreate() .Build(); // Apply v1 - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var v1Ops = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - v1Ops, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, v1, _logger); // v2 changes to expression index (different name since it's semantically different) var v2 = Schema @@ -1263,19 +1184,7 @@ public void UpgradeIndex_ExpressionToColumn_RequiresDropAndCreate() .Build(); // Apply v1 - var emptySchema = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, _logger) - ).Value; - var v1Ops = ( - (OperationsResultOk)SchemaDiff.Calculate(emptySchema, v1, logger: _logger) - ).Value; - _ = MigrationRunner.Apply( - connection, - v1Ops, - SqliteDdlGenerator.Generate, - MigrationOptions.Default, - _logger - ); + SeedSchema(connection, v1, _logger); // v2 changes back to simple column index (different name) var v2 = Schema diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteRlsMigrationTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteRlsMigrationTests.cs index a3004e37..b84434eb 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteRlsMigrationTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteRlsMigrationTests.cs @@ -1,5 +1,3 @@ -using System.Globalization; - namespace Nimblesite.DataProvider.Migration.Tests; // Implements [RLS-SQLITE] tests from docs/specs/rls-spec.md. @@ -9,26 +7,27 @@ namespace Nimblesite.DataProvider.Migration.Tests; /// public sealed class SqliteRlsMigrationTests { - private static readonly ILogger Logger = NullLogger.Instance; - [Fact] public void Sqlite_EnableRls_CreatesRlsContextTable() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - Apply(connection, [new EnableRlsOperation("main", "Documents")]); + SqliteTestDb.Apply(connection, [new EnableRlsOperation("main", "Documents")]); - Assert.Equal(1, CountMasterRows(connection, "table", "__rls_context")); + Assert.Equal(1, SqliteTestDb.CountMasterRows(connection, "table", "__rls_context")); }); } [Fact] public void Sqlite_CreatePolicy_Insert_TriggerBlocksCrossOwnerInsert() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - ApplySchema(connection, DocumentsSchema(OwnerPolicy([RlsOperation.Insert]))); - SetUser(connection, "user-a"); + SqliteTestDb.ApplySchema( + connection, + DocumentsSchema(OwnerPolicy([RlsOperation.Insert])) + ); + SqliteTestDb.SetUser(connection, "user-a"); InsertDocument(connection, "doc-a", "user-a"); @@ -42,14 +41,17 @@ public void Sqlite_CreatePolicy_Insert_TriggerBlocksCrossOwnerInsert() [Fact] public void Sqlite_CreatePolicy_Update_TriggerBlocksCrossOwnerUpdate() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - ApplySchema(connection, DocumentsSchema(OwnerPolicy([RlsOperation.Update]))); - SetUser(connection, "user-a"); + SqliteTestDb.ApplySchema( + connection, + DocumentsSchema(OwnerPolicy([RlsOperation.Update])) + ); + SqliteTestDb.SetUser(connection, "user-a"); InsertDocument(connection, "doc-a", "user-a"); var ex = Assert.Throws(() => - Execute(connection, "UPDATE [Documents] SET [OwnerId]='user-b'") + SqliteTestDb.Execute(connection, "UPDATE [Documents] SET [OwnerId]='user-b'") ); Assert.Contains("RLS-SQLITE", ex.Message, StringComparison.Ordinal); }); @@ -58,15 +60,18 @@ public void Sqlite_CreatePolicy_Update_TriggerBlocksCrossOwnerUpdate() [Fact] public void Sqlite_CreatePolicy_Delete_TriggerBlocksCrossOwnerDelete() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - ApplySchema(connection, DocumentsSchema(OwnerPolicy([RlsOperation.Delete]))); - SetUser(connection, "user-a"); + SqliteTestDb.ApplySchema( + connection, + DocumentsSchema(OwnerPolicy([RlsOperation.Delete])) + ); + SqliteTestDb.SetUser(connection, "user-a"); InsertDocument(connection, "doc-a", "user-a"); - SetUser(connection, "user-b"); + SqliteTestDb.SetUser(connection, "user-b"); var ex = Assert.Throws(() => - Execute(connection, "DELETE FROM [Documents] WHERE [Id]='doc-a'") + SqliteTestDb.Execute(connection, "DELETE FROM [Documents] WHERE [Id]='doc-a'") ); Assert.Contains("RLS-SQLITE", ex.Message, StringComparison.Ordinal); }); @@ -75,43 +80,44 @@ public void Sqlite_CreatePolicy_Delete_TriggerBlocksCrossOwnerDelete() [Fact] public void Sqlite_CreatePolicy_GroupMembership_TriggerUsesSubquery() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - ApplySchema(connection, GroupMembershipSchema()); - SetUser(connection, "user-a"); + SqliteTestDb.ApplySchema(connection, GroupMembershipSchema()); + SqliteTestDb.SetUser(connection, "user-a"); Assert.Throws(() => InsertDocument(connection, "doc-a", "user-a")); InsertMembership(connection, "membership-a", "user-a"); InsertDocument(connection, "doc-b", "user-a"); - Assert.Equal(1, CountRows(connection, "Documents")); + Assert.Equal(1, SqliteTestDb.CountRows(connection, "Documents")); }); } [Fact] public void Sqlite_SelectPolicy_CreatesSecureView() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - ApplySchema(connection, DocumentsSchema(OwnerPolicy([RlsOperation.Select]))); + SqliteTestDb.ApplySchema( + connection, + DocumentsSchema(OwnerPolicy([RlsOperation.Select])) + ); InsertDocument(connection, "doc-a", "user-a"); InsertDocument(connection, "doc-b", "user-b"); - SetUser(connection, "user-a"); + SqliteTestDb.SetUser(connection, "user-a"); - Assert.Equal(1, CountRows(connection, "Documents_secure")); + Assert.Equal(1, SqliteTestDb.CountRows(connection, "Documents_secure")); }); } [Fact] public void Sqlite_SchemaInspector_ReadsBackTriggers() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - ApplySchema(connection, DocumentsSchema(OwnerPolicy([RlsOperation.All]))); + SqliteTestDb.ApplySchema(connection, DocumentsSchema(OwnerPolicy([RlsOperation.All]))); - var inspected = ( - (SchemaResultOk)SqliteSchemaInspector.Inspect(connection, Logger) - ).Value; + var inspected = SqliteTestDb.Inspect(connection); var rls = inspected.Tables.Single(t => t.Name == "Documents").RowLevelSecurity; Assert.NotNull(rls); @@ -119,6 +125,97 @@ public void Sqlite_SchemaInspector_ReadsBackTriggers() }); } + // Implements [RLS-DIFF]: inspection must make an unchanged ALL policy a no-op. + [Fact] + public void Sqlite_AllPolicy_RerunHasNoPolicyOperations() + { + SqliteTestDb.WithDb(test: connection => + { + var desired = DocumentsSchema(policy: OwnerPolicy(ops: [RlsOperation.All])); + SqliteTestDb.ApplySchema(connection: connection, schema: desired); + var operations = Assert + .IsType( + SchemaDiff.Calculate( + current: SqliteTestDb.Inspect(connection: connection), + desired: desired + ) + ) + .Value; + Assert.Empty(operations); + }); + } + + // Implements [RLS-DIFF]: SELECT-only policies must replace stale secure-view predicates. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Sqlite_SelectPolicy_ChangedUsingPredicateBlocksPreviouslyVisibleRows( + bool legacyView + ) + { + SqliteTestDb.WithDb(test: connection => + { + var policy = OwnerPolicy(ops: [RlsOperation.Select]) with { WithCheckLql = null }; + SqliteTestDb.ApplySchema( + connection: connection, + schema: DocumentsSchema(policy: policy) + ); + if (legacyView) + { + SqliteTestDb.Execute( + connection: connection, + sql: LegacySelectViewDdl(policy: policy) + ); + } + SqliteTestDb.SetUser(connection: connection, userId: "user-a"); + InsertDocument(connection: connection, id: "doc-a", ownerId: "user-a"); + Assert.Equal( + expected: 1, + actual: SqliteTestDb.CountRows( + connection: connection, + tableName: "Documents_secure" + ) + ); + + var desired = DocumentsSchema(policy: policy with { UsingLql = "false" }); + SqliteTestDb.ApplySchema(connection: connection, schema: desired); + + Assert.Equal( + expected: 0, + actual: SqliteTestDb.CountRows( + connection: connection, + tableName: "Documents_secure" + ) + ); + Assert.Empty( + Assert + .IsType( + SchemaDiff.Calculate( + current: SqliteTestDb.Inspect(connection: connection), + desired: desired + ) + ) + .Value + ); + }); + } + + // Implements [RLS-DIFF]: SQL comments cannot hide a stored policy predicate. + [Fact] + public void Sqlite_TriggerPredicate_IgnoresKeywordsInsideComments() + { + var predicate = SqliteRlsPredicateReader.TriggerPredicate( + triggerSql: """ + CREATE TRIGGER [rls_insert_owner_Documents] -- BEGIN + BEFORE INSERT ON [Documents] + BEGIN + SELECT RAISE(ABORT, 'RLS') WHERE NOT (NEW.[OwnerId] = 'allowed'); + END -- END + """ + ); + Assert.Equal(expected: "[OwnerId] = 'allowed'", actual: predicate); + } + [Fact] public void Sqlite_RestrictivePolicy_EmitsWarning() { @@ -139,23 +236,41 @@ public void Sqlite_RestrictivePolicy_EmitsWarning() [Fact] public void Sqlite_DisableRls_DropsSecureView() { - WithDb(connection => + SqliteTestDb.WithDb(connection => { - ApplySchema(connection, DocumentsSchema(OwnerPolicy([RlsOperation.Select]))); + SqliteTestDb.ApplySchema( + connection, + DocumentsSchema(OwnerPolicy([RlsOperation.Select])) + ); - Apply( + SqliteTestDb.Apply( connection, [new DisableRlsOperation("main", "Documents")], MigrationOptions.Destructive ); - Assert.Equal(0, CountMasterRows(connection, "view", "Documents_secure")); + Assert.Equal(0, SqliteTestDb.CountMasterRows(connection, "view", "Documents_secure")); }); } private static SchemaDefinition DocumentsSchema(RlsPolicyDefinition policy) => new() { Name = "sqlite", Tables = [DocumentsTable(policy)] }; + private static string LegacySelectViewDdl(RlsPolicyDefinition policy) => + SqliteDdlGenerator + .Generate( + operation: new CreateRlsPolicyOperation( + Schema: "main", + TableName: "Documents", + Policy: policy + ) + ) + .Replace( + oldValue: SqliteRlsViewMetadata.Encode(policyName: policy.Name), + newValue: string.Empty, + comparisonType: StringComparison.Ordinal + ); + private static TableDefinition DocumentsTable(RlsPolicyDefinition policy) => new() { @@ -221,85 +336,15 @@ private static RlsPolicyDefinition GroupMembershipPolicy() => """, }; - private static void ApplySchema(SqliteConnection connection, SchemaDefinition schema) - { - var current = ((SchemaResultOk)SqliteSchemaInspector.Inspect(connection, Logger)).Value; - var ops = ((OperationsResultOk)SchemaDiff.Calculate(current, schema, logger: Logger)).Value; - Apply(connection, ops); - } - - private static void Apply( - SqliteConnection connection, - IReadOnlyList ops, - MigrationOptions? options = null - ) - { - var result = MigrationRunner.Apply( - connection, - ops, - SqliteDdlGenerator.Generate, - options ?? MigrationOptions.Default, - Logger - ); - Assert.True(result is MigrationApplyResultOk); - } - - private static void SetUser(SqliteConnection connection, string userId) - { - Execute(connection, "DELETE FROM [__rls_context]"); - Execute(connection, $"INSERT INTO [__rls_context]([current_user_id]) VALUES ('{userId}')"); - } - private static void InsertDocument(SqliteConnection connection, string id, string ownerId) => - Execute( + SqliteTestDb.Execute( connection, $"INSERT INTO [Documents]([Id], [OwnerId], [Title]) VALUES ('{id}', '{ownerId}', 't')" ); private static void InsertMembership(SqliteConnection connection, string id, string userId) => - Execute( + SqliteTestDb.Execute( connection, $"INSERT INTO [UserGroupMemberships]([Id], [UserId]) VALUES ('{id}', '{userId}')" ); - - private static void Execute(SqliteConnection connection, string sql) - { - using var command = connection.CreateCommand(); - command.CommandText = sql; - command.ExecuteNonQuery(); - } - - private static int CountRows(SqliteConnection connection, string tableName) => - Count(connection, $"SELECT COUNT(*) FROM [{tableName}]"); - - private static int CountMasterRows(SqliteConnection connection, string type, string name) => - Count( - connection, - $"SELECT COUNT(*) FROM sqlite_master WHERE type='{type}' AND name='{name}'" - ); - - private static int Count(SqliteConnection connection, string sql) - { - using var command = connection.CreateCommand(); - command.CommandText = sql; - return Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture); - } - - private static void WithDb(Action test) - { - var dbPath = Path.Combine(Path.GetTempPath(), $"sqliterls_{Guid.NewGuid()}.db"); - using var connection = new SqliteConnection($"Data Source={dbPath}"); - connection.Open(); - try - { - test(connection); - } - finally - { - if (File.Exists(dbPath)) - { - File.Delete(dbPath); - } - } - } } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteTestDb.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteTestDb.cs new file mode 100644 index 00000000..13529b6b --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SqliteTestDb.cs @@ -0,0 +1,115 @@ +using System.Globalization; + +namespace Nimblesite.DataProvider.Migration.Tests; + +/// +/// Shared SQLite E2E test helpers: temp-file databases, schema application +/// via inspect → diff → apply, and row counting. +/// +internal static class SqliteTestDb +{ + private static readonly ILogger Logger = NullLogger.Instance; + + public static void WithDb(Action test) + { + var dbPath = Path.Combine(Path.GetTempPath(), $"sqlitemig_{Guid.NewGuid()}.db"); + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + try + { + test(connection); + } + finally + { + if (File.Exists(dbPath)) + { + File.Delete(dbPath); + } + } + } + + public static SchemaDefinition Inspect(SqliteConnection connection) => + Assert.IsType(SqliteSchemaInspector.Inspect(connection, Logger)).Value; + + public static void ApplySchema( + SqliteConnection connection, + SchemaDefinition schema, + bool allowDestructive = false + ) + { + var result = TryApplySchema(connection, schema, allowDestructive); + var error = result is MigrationApplyResultError failure ? failure.Value.Message : null; + Assert.True(result is MigrationApplyResultOk, $"Migration failed: {error}"); + } + + /// + /// Inspect → diff → apply without asserting success, for tests that + /// expect the migration to fail loudly. + /// + public static MigrationApplyResult TryApplySchema( + SqliteConnection connection, + SchemaDefinition schema, + bool allowDestructive = false + ) + { + var current = Inspect(connection); + var ops = Assert + .IsType( + SchemaDiff.Calculate(current, schema, allowDestructive, logger: Logger) + ) + .Value; + return MigrationRunner.Apply( + connection, + ops, + SqliteDdlGenerator.Generate, + allowDestructive ? MigrationOptions.Destructive : MigrationOptions.Default, + Logger + ); + } + + public static void Apply( + SqliteConnection connection, + IReadOnlyList ops, + MigrationOptions? options = null + ) + { + var result = MigrationRunner.Apply( + connection, + ops, + SqliteDdlGenerator.Generate, + options ?? MigrationOptions.Default, + Logger + ); + var error = result is MigrationApplyResultError failure ? failure.Value.Message : null; + Assert.True(result is MigrationApplyResultOk, $"Migration failed: {error}"); + } + + public static void SetUser(SqliteConnection connection, string userId) + { + Execute(connection, "DELETE FROM [__rls_context]"); + Execute(connection, $"INSERT INTO [__rls_context]([current_user_id]) VALUES ('{userId}')"); + } + + public static void Execute(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + public static int CountRows(SqliteConnection connection, string tableName) => + Count(connection, $"SELECT COUNT(*) FROM [{tableName}]"); + + public static int CountMasterRows(SqliteConnection connection, string type, string name) => + Count( + connection, + $"SELECT COUNT(*) FROM sqlite_master WHERE type='{type}' AND name='{name}'" + ); + + public static int Count(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture); + } +} diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/TriggerYamlSerializerTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/TriggerYamlSerializerTests.cs new file mode 100644 index 00000000..dd3c8f1c --- /dev/null +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/TriggerYamlSerializerTests.cs @@ -0,0 +1,93 @@ +namespace Nimblesite.DataProvider.Migration.Tests; + +// Implements [MIG-TRIGGER-YAML] from docs/specs/declarative-triggers-spec.md +// (GitHub issue 82). + +/// +/// YAML round-trip tests for declarative trigger definitions. +/// +public sealed class TriggerYamlSerializerTests +{ + private const string TriggerYaml = """ + name: tenant_app + tables: + - name: tenant_members + columns: + - name: id + type: Uuid + isNullable: false + - name: tenant_id + type: Uuid + isNullable: false + - name: role + type: VarChar(50) + isNullable: false + primaryKey: + columns: + - id + triggers: + - name: assert_not_last_owner + timing: Before + events: [Update, Delete] + forEachRow: true + raiseWhen: | + old.role = 'owner' and not exists( + tenant_members + |> filter(fn(m) => m.tenant_id = old.tenant_id and m.role = 'owner' and m.id <> old.id) + ) + errorMessage: cannot remove the last owner of a tenant + """; + + [Fact] + public void TriggerDefinition_YamlRoundTrip_PreservesDeclaration() + { + var schema = SchemaYamlSerializer.FromYaml(TriggerYaml); + var back = SchemaYamlSerializer.ToYaml(schema); + + Assert.Contains("triggers:", back, StringComparison.Ordinal); + Assert.Contains("assert_not_last_owner", back, StringComparison.Ordinal); + Assert.Contains("Update", back, StringComparison.Ordinal); + Assert.Contains("Delete", back, StringComparison.Ordinal); + Assert.Contains("raiseWhen", back, StringComparison.Ordinal); + Assert.Contains("not exists", back, StringComparison.Ordinal); + Assert.Contains("errorMessage", back, StringComparison.Ordinal); + Assert.Contains("cannot remove the last owner", back, StringComparison.Ordinal); + } + + [Fact] + public void TriggerDefinition_YamlRoundTrip_OmitsDefaults() + { + // Defaults: timing Before, forEachRow true. These must not appear in + // serialized YAML, and a second round-trip must preserve the trigger. + var schema = SchemaYamlSerializer.FromYaml(TriggerYaml); + var back = SchemaYamlSerializer.ToYaml(schema); + + Assert.DoesNotContain("timing:", back, StringComparison.Ordinal); + Assert.DoesNotContain("forEachRow:", back, StringComparison.Ordinal); + + var reparsed = SchemaYamlSerializer.FromYaml(back); + var rereserialized = SchemaYamlSerializer.ToYaml(reparsed); + Assert.Contains("assert_not_last_owner", rereserialized, StringComparison.Ordinal); + } + + [Fact] + public void Triggers_Absent_DoNotAppearInYaml() + { + var schema = new SchemaDefinition + { + Name = "t", + Tables = + [ + new TableDefinition + { + Name = "plain", + Columns = [new ColumnDefinition { Name = "id", Type = new UuidType() }], + }, + ], + }; + + var yaml = SchemaYamlSerializer.ToYaml(schema); + + Assert.DoesNotContain("triggers", yaml, StringComparison.Ordinal); + } +} diff --git a/Other/Nimblesite.Sql.Model/ConstantExpressionEvaluator.cs b/Other/Nimblesite.Sql.Model/ConstantExpressionEvaluator.cs new file mode 100644 index 00000000..31732e48 --- /dev/null +++ b/Other/Nimblesite.Sql.Model/ConstantExpressionEvaluator.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Nimblesite.Sql.Model; + +/// +/// Evaluates the constant value of a LINQ expression subtree without using +/// . Implements [MIG-AOT-DYNCODE]: +/// Expression.Lambda(expr).Compile().DynamicInvoke() carries +/// +/// (IL3050) and breaks Native AOT. The cases that occur when translating a LINQ +/// predicate to SQL — literals, captured locals/fields, static fields/properties, +/// and member chains over those — are all walkable without emitting code. +/// +internal static class ConstantExpressionEvaluator +{ + /// + /// Returns the evaluated value of , or + /// null when the subtree cannot be reduced to a value without dynamic + /// code (matching the prior catch fallback behaviour). + /// + internal static object? TryEvaluate(Expression expression) => + expression switch + { + ConstantExpression constant => constant.Value, + MemberExpression member => EvaluateMember(member), + UnaryExpression { NodeType: ExpressionType.Convert } convert => TryEvaluate( + convert.Operand + ), + UnaryExpression { NodeType: ExpressionType.ConvertChecked } convert => TryEvaluate( + convert.Operand + ), + _ => null, + }; + + private static object? EvaluateMember(MemberExpression member) + { + // A static member (member.Expression is null) or a member over an already + // reducible subtree (a captured closure constant, a nested field, etc.). + var instance = member.Expression is null ? null : TryEvaluate(member.Expression); + + if (member.Expression is not null && instance is null) + { + return null; + } + + return member.Member switch + { + FieldInfo field => field.GetValue(instance), + PropertyInfo property when property.GetIndexParameters().Length == 0 => + property.GetValue(instance), + _ => null, + }; + } +} diff --git a/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs b/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs index 9662ed54..b4c867b9 100644 --- a/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementLinqExtensions.cs @@ -77,14 +77,7 @@ public static SelectStatementBuilder Where( this SelectStatementBuilder builder, string columnName, object value - ) => - builder.AddWhereCondition( - WhereCondition.Comparison( - ColumnInfo.Named(columnName), - ComparisonOperator.Eq, - FormatValue(value) - ) - ); + ) => builder.Where(columnName: columnName, @operator: ComparisonOperator.Eq, value: value); /// /// Adds a WHERE condition with a comparison operator @@ -96,7 +89,11 @@ public static SelectStatementBuilder Where( object value ) => builder.AddWhereCondition( - WhereCondition.Comparison(ColumnInfo.Named(columnName), @operator, FormatValue(value)) + WhereCondition.Comparison( + ColumnInfo.Named(columnName), + @operator, + SelectStatementVisitor.FormatValue(value, formatProvider: null) + ) ); /// @@ -107,15 +104,11 @@ public static SelectStatementBuilder And( string columnName, object value ) => - builder - .AddWhereCondition(WhereCondition.And()) - .AddWhereCondition( - WhereCondition.Comparison( - ColumnInfo.Named(columnName), - ComparisonOperator.Eq, - FormatValue(value) - ) - ); + builder.AddChainedEquality( + connector: WhereCondition.And(), + columnName: columnName, + value: value + ); /// /// Adds an OR condition @@ -125,15 +118,11 @@ public static SelectStatementBuilder Or( string columnName, object value ) => - builder - .AddWhereCondition(WhereCondition.Or()) - .AddWhereCondition( - WhereCondition.Comparison( - ColumnInfo.Named(columnName), - ComparisonOperator.Eq, - FormatValue(value) - ) - ); + builder.AddChainedEquality( + connector: WhereCondition.Or(), + columnName: columnName, + value: value + ); /// /// Adds an INNER JOIN @@ -145,22 +134,15 @@ public static SelectStatementBuilder InnerJoin( string rightColumn, string? leftTableAlias = null, string? rightTableAlias = null - ) - { - var leftRef = leftTableAlias != null ? $"{leftTableAlias}.{leftColumn}" : leftColumn; - var rightRef = rightTableAlias != null ? $"{rightTableAlias}.{rightColumn}" : rightColumn; - var condition = $"{leftRef} = {rightRef}"; - - // Add the right table to the tables collection - builder.AddTable(rightTable, rightTableAlias); - - return builder.AddJoin( - leftTableAlias ?? builder.GetFirstTableName(), - rightTable, - condition, - "INNER" + ) => + builder.AddEquiJoin( + rightTable: rightTable, + leftColumn: leftColumn, + rightColumn: rightColumn, + leftTableAlias: leftTableAlias, + rightTableAlias: rightTableAlias, + joinType: "INNER" ); - } /// /// Adds a LEFT JOIN @@ -172,22 +154,15 @@ public static SelectStatementBuilder LeftJoin( string rightColumn, string? leftTableAlias = null, string? rightTableAlias = null - ) - { - var leftRef = leftTableAlias != null ? $"{leftTableAlias}.{leftColumn}" : leftColumn; - var rightRef = rightTableAlias != null ? $"{rightTableAlias}.{rightColumn}" : rightColumn; - var condition = $"{leftRef} = {rightRef}"; - - // Add the right table to the tables collection - builder.AddTable(rightTable, rightTableAlias); - - return builder.AddJoin( - leftTableAlias ?? builder.GetFirstTableName(), - rightTable, - condition, - "LEFT" + ) => + builder.AddEquiJoin( + rightTable: rightTable, + leftColumn: leftColumn, + rightColumn: rightColumn, + leftTableAlias: leftTableAlias, + rightTableAlias: rightTableAlias, + joinType: "LEFT" ); - } /// /// Adds ORDER BY ascending @@ -258,6 +233,63 @@ public static SelectStatement ToSqlStatement(this SelectStatementBuilder builder // Helper methods + /// + /// Adds an equi-join of the given type (INNER or LEFT). + /// + private static SelectStatementBuilder AddEquiJoin( + this SelectStatementBuilder builder, + string rightTable, + string leftColumn, + string rightColumn, + string? leftTableAlias, + string? rightTableAlias, + string joinType + ) + { + var leftRef = leftTableAlias != null ? $"{leftTableAlias}.{leftColumn}" : leftColumn; + var rightRef = rightTableAlias != null ? $"{rightTableAlias}.{rightColumn}" : rightColumn; + var condition = $"{leftRef} = {rightRef}"; + + // Add the right table to the tables collection + builder.AddTable(rightTable, rightTableAlias); + + return builder.AddJoin( + leftTableAlias ?? builder.GetFirstTableName(), + rightTable, + condition, + joinType + ); + } + + /// + /// Adds a connector (AND/OR) followed by an equality comparison. + /// + private static SelectStatementBuilder AddChainedEquality( + this SelectStatementBuilder builder, + WhereCondition connector, + string columnName, + object value + ) => + builder + .AddWhereCondition(connector) + .AddWhereCondition( + WhereCondition.Comparison( + ColumnInfo.Named(columnName), + ComparisonOperator.Eq, + SelectStatementVisitor.FormatValue(value, formatProvider: null) + ) + ); + + /// + /// Wraps an expression in parentheses and adds its conditions. + /// + private static void ProcessInParens(Expression expr, List conditions) + { + conditions.Add(WhereCondition.OpenParen()); + ProcessExpression(expr, conditions); + conditions.Add(WhereCondition.CloseParen()); + } + private static string GetFirstTableName(this SelectStatementBuilder _) => // Since we can't access private fields, we'll need to track this separately // or modify the builder to expose this information @@ -327,13 +359,9 @@ List conditions break; case ExpressionType.OrElse: - conditions.Add(WhereCondition.OpenParen()); - ProcessExpression(binary.Left, conditions); - conditions.Add(WhereCondition.CloseParen()); + ProcessInParens(binary.Left, conditions); conditions.Add(WhereCondition.Or()); - conditions.Add(WhereCondition.OpenParen()); - ProcessExpression(binary.Right, conditions); - conditions.Add(WhereCondition.CloseParen()); + ProcessInParens(binary.Right, conditions); break; case ExpressionType.Equal: @@ -352,23 +380,17 @@ private static void AddComparisonCondition( List conditions ) { - var columnName = ExtractColumnName(binary.Left); - var value = ExtractValue(binary.Right); - var op = binary.NodeType switch - { - ExpressionType.Equal => ComparisonOperator.Eq, - ExpressionType.NotEqual => ComparisonOperator.NotEq, - ExpressionType.LessThan => ComparisonOperator.LessThan, - ExpressionType.LessThanOrEqual => ComparisonOperator.LessOrEq, - ExpressionType.GreaterThan => ComparisonOperator.GreaterThan, - ExpressionType.GreaterThanOrEqual => ComparisonOperator.GreaterOrEq, - _ => ComparisonOperator.Eq, - }; + var columnName = SelectStatementVisitor.ExtractColumnName(binary.Left); + var value = SelectStatementVisitor.ExtractValue(binary.Right); if (columnName != null && value != null) { conditions.Add( - WhereCondition.Comparison(ColumnInfo.Named(columnName), op, FormatValue(value)) + WhereCondition.Comparison( + ColumnInfo.Named(columnName), + SelectStatementVisitor.ComparisonOperatorFor(binary.NodeType), + SelectStatementVisitor.FormatValue(value, formatProvider: null) + ) ); } } @@ -381,8 +403,8 @@ List conditions // Handle common string methods if (method.Method.Name == "Contains" && method.Object != null) { - var columnName = ExtractColumnName(method.Object); - var value = ExtractValue(method.Arguments[0]); + var columnName = SelectStatementVisitor.ExtractColumnName(method.Object); + var value = SelectStatementVisitor.ExtractValue(method.Arguments[0]); if (columnName != null && value != null) { conditions.Add( @@ -396,8 +418,8 @@ List conditions } else if (method.Method.Name == "StartsWith" && method.Object != null) { - var columnName = ExtractColumnName(method.Object); - var value = ExtractValue(method.Arguments[0]); + var columnName = SelectStatementVisitor.ExtractColumnName(method.Object); + var value = SelectStatementVisitor.ExtractValue(method.Arguments[0]); if (columnName != null && value != null) { conditions.Add( @@ -410,42 +432,4 @@ List conditions } } } - - private static string? ExtractColumnName(Expression expr) => - expr switch - { - MemberExpression member => member.Member.Name, - UnaryExpression unary => ExtractColumnName(unary.Operand), - _ => null, - }; - - private static object? ExtractValue(Expression expr) - { - try - { - var lambda = Expression.Lambda(expr); - var compiled = lambda.Compile(); - return compiled.DynamicInvoke(); - } - catch (InvalidOperationException) - { - // Expected - expression cannot be evaluated at compile time - return default; - } - catch (ArgumentException) - { - // Expected - invalid expression structure - return default; - } - } - - private static string FormatValue(object? value) => - value switch - { - null => "NULL", - string s => $"'{s.Replace("'", "''", StringComparison.Ordinal)}'", - bool b => b ? "1" : "0", - DateTime dt => $"'{dt:yyyy-MM-dd HH:mm:ss}'", - _ => value.ToString() ?? "NULL", - }; } diff --git a/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs b/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs index 32b0164c..ed969357 100644 --- a/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs +++ b/Other/Nimblesite.Sql.Model/SelectStatementVisitor.cs @@ -10,10 +10,7 @@ internal sealed class SelectStatementVisitor : ExpressionVisitor { private readonly SelectStatementBuilder _builder; - internal SelectStatementVisitor(SelectStatementBuilder builder) - { - _builder = builder; - } + internal SelectStatementVisitor(SelectStatementBuilder builder) => _builder = builder; protected override Expression VisitMethodCall(MethodCallExpression node) { @@ -22,76 +19,23 @@ protected override Expression VisitMethodCall(MethodCallExpression node) switch (method.Name) { case "Where": - if (node.Arguments.Count >= 2) - { - Visit(node.Arguments[0]); - var lambda = GetLambda(node.Arguments[1]); - if (lambda != null) - { - ProcessWhereExpression(lambda.Body); - } - } - return node; + return VisitSourceAndProcessLambda(node, ProcessWhereExpression); case "Select": - if (node.Arguments.Count >= 2) - { - Visit(node.Arguments[0]); - var lambda = GetLambda(node.Arguments[1]); - if (lambda != null) - { - ProcessSelectExpression(lambda.Body); - } - } - return node; + return VisitSourceAndProcessLambda(node, ProcessSelectExpression); case "OrderBy": case "ThenBy": - if (node.Arguments.Count >= 2) - { - Visit(node.Arguments[0]); - var lambda = GetLambda(node.Arguments[1]); - if (lambda != null && lambda.Body is MemberExpression member) - { - _builder.AddOrderBy(member.Member.Name, "ASC"); - } - } - return node; + return AddOrderByFromLambda(node, "ASC"); case "OrderByDescending": case "ThenByDescending": - if (node.Arguments.Count >= 2) - { - Visit(node.Arguments[0]); - var lambda = GetLambda(node.Arguments[1]); - if (lambda != null && lambda.Body is MemberExpression member) - { - _builder.AddOrderBy(member.Member.Name, "DESC"); - } - } - return node; + return AddOrderByFromLambda(node, "DESC"); case "Take": - if (node.Arguments.Count >= 2) - { - Visit(node.Arguments[0]); - if (node.Arguments[1] is ConstantExpression constant) - { - _builder.WithLimit(constant.Value?.ToString() ?? "0"); - } - } - return node; - + return ApplyRowCountConstraint(node, limit => _builder.WithLimit(limit)); case "Skip": - if (node.Arguments.Count >= 2) - { - Visit(node.Arguments[0]); - if (node.Arguments[1] is ConstantExpression constant) - { - _builder.WithOffset(constant.Value?.ToString() ?? "0"); - } - } - return node; + return ApplyRowCountConstraint(node, offset => _builder.WithOffset(offset)); case "Distinct": Visit(node.Arguments[0]); @@ -99,17 +43,10 @@ protected override Expression VisitMethodCall(MethodCallExpression node) return node; case "GroupBy": - if (node.Arguments.Count >= 2) - { - Visit(node.Arguments[0]); - var lambda = GetLambda(node.Arguments[1]); - if (lambda != null) - { - var columns = ExtractColumns(lambda.Body); - _builder.AddGroupBy(columns); - } - } - return node; + return VisitSourceAndProcessLambda( + node, + body => _builder.AddGroupBy(ExtractColumns(body)) + ); default: return base.VisitMethodCall(node); @@ -124,6 +61,64 @@ protected override Expression VisitMethodCall(MethodCallExpression node) _ => null, }; + private MethodCallExpression VisitSourceAndProcessLambda( + MethodCallExpression node, + Action processBody + ) + { + if (node.Arguments.Count >= 2) + { + Visit(node.Arguments[0]); + var lambda = GetLambda(node.Arguments[1]); + if (lambda != null) + { + processBody(lambda.Body); + } + } + return node; + } + + private MethodCallExpression AddOrderByFromLambda(MethodCallExpression node, string direction) + { + if (node.Arguments.Count >= 2) + { + Visit(node.Arguments[0]); + var lambda = GetLambda(node.Arguments[1]); + if (lambda != null && lambda.Body is MemberExpression member) + { + _builder.AddOrderBy(member.Member.Name, direction); + } + } + return node; + } + + private MethodCallExpression ApplyRowCountConstraint( + MethodCallExpression node, + Action apply + ) + { + if (node.Arguments.Count >= 2) + { + Visit(node.Arguments[0]); + if (node.Arguments[1] is ConstantExpression constant) + { + apply(constant.Value?.ToString() ?? "0"); + } + } + return node; + } + + private static string? TryFoldBooleanConstants( + BinaryExpression binary, + Func combine + ) => + binary.Left is ConstantExpression { Value: bool left } leftConst + && leftConst.Type == typeof(bool) + && binary.Right is ConstantExpression { Value: bool right } rightConst + && rightConst.Type == typeof(bool) + ? BooleanSql(combine(left, right)) + : null; + private void ProcessSelectExpression(Expression expression) { var columns = ExtractColumns(expression); @@ -150,9 +145,9 @@ private void ProcessWhereExpression(Expression expression) private static string? TryConvertToSingleSqlExpression(Expression expression) => expression switch { - ConstantExpression constant when constant.Type == typeof(bool) => (bool)constant.Value! - ? "1 = 1" - : "1 = 0", + ConstantExpression constant + when constant.Type == typeof(bool) && constant.Value is bool boolValue => + BooleanSql(boolValue), UnaryExpression { NodeType: ExpressionType.Not } unary => ConvertExpressionToSql(unary), @@ -165,32 +160,20 @@ BinaryExpression binary when IsComplexPredicateBuilderExpression(binary) => private static bool IsComplexPredicateBuilderExpression(BinaryExpression binary) => // Detect if this is a complex PredicateBuilder expression // PredicateBuilder expressions typically have constant boolean values at the leaf nodes - (binary.NodeType is ExpressionType.AndAlso or ExpressionType.OrElse) - && (HasDirectConstantBooleanNodes(binary) || IsPredicateBuilderPattern(binary)); + binary.NodeType + is ExpressionType.AndAlso + or ExpressionType.OrElse + && ( + HasDirectConstantBooleanNodes(binary) + || IsConstantBooleanOrPredicateBuilder(binary.Left) + || IsConstantBooleanOrPredicateBuilder(binary.Right) + ); private static bool HasDirectConstantBooleanNodes(BinaryExpression binary) => // Check for direct constant boolean nodes (immediate children) (binary.Left is ConstantExpression leftConst && leftConst.Type == typeof(bool)) || (binary.Right is ConstantExpression rightConst && rightConst.Type == typeof(bool)); - private static bool IsPredicateBuilderPattern(BinaryExpression binary) => - // More sophisticated detection for PredicateBuilder patterns - // Look for patterns like: (constant || expression) or (expression && constant) - ( - binary.NodeType is ExpressionType.OrElse - && ( - IsConstantBooleanOrPredicateBuilder(binary.Left) - || IsConstantBooleanOrPredicateBuilder(binary.Right) - ) - ) - || ( - binary.NodeType is ExpressionType.AndAlso - && ( - IsConstantBooleanOrPredicateBuilder(binary.Left) - || IsConstantBooleanOrPredicateBuilder(binary.Right) - ) - ); - private static bool IsConstantBooleanOrPredicateBuilder(Expression expression) => expression switch { @@ -199,22 +182,12 @@ private static bool IsConstantBooleanOrPredicateBuilder(Expression expression) = _ => false, }; - private static bool HasConstantBooleanNodes(Expression expression) => - expression switch - { - ConstantExpression constant => constant.Type == typeof(bool), - BinaryExpression binary => HasConstantBooleanNodes(binary.Left) - || HasConstantBooleanNodes(binary.Right), - UnaryExpression unary => HasConstantBooleanNodes(unary.Operand), - _ => false, - }; - private static string ConvertExpressionToSql(Expression expression) => expression switch { - ConstantExpression constant when constant.Type == typeof(bool) => (bool)constant.Value! - ? "1 = 1" - : "1 = 0", + ConstantExpression constant + when constant.Type == typeof(bool) && constant.Value is bool boolValue => + BooleanSql(boolValue), UnaryExpression { NodeType: ExpressionType.Not } unary => $"NOT ({ConvertExpressionToSql(unary.Operand)})", @@ -234,32 +207,17 @@ private static string ConvertExpressionToSql(Expression expression) => private static string ConvertComparisonToSql(BinaryExpression binary) { - var columnName = ExtractColumnName(binary.Left) ?? ExtractColumnName(binary.Right); - var value = ExtractValue(binary.Right) ?? ExtractValue(binary.Left); + var (columnName, value) = ExtractComparisonOperands(binary); // Handle NULL comparisons specially if (value == null) { - return binary.NodeType switch - { - ExpressionType.Equal => $"{columnName} IS NULL", - ExpressionType.NotEqual => $"{columnName} IS NOT NULL", - _ => $"{columnName} IS NULL", - }; + return NullComparisonSql(columnName, binary.NodeType); } - var op = binary.NodeType switch - { - ExpressionType.Equal => "=", - ExpressionType.NotEqual => "!=", - ExpressionType.LessThan => "<", - ExpressionType.LessThanOrEqual => "<=", - ExpressionType.GreaterThan => ">", - ExpressionType.GreaterThanOrEqual => ">=", - _ => "=", - }; + var op = ComparisonOperatorFor(binary.NodeType).ToSql(); - return $"{columnName} {op} {FormatValue(value)}"; + return $"{columnName} {op} {FormatValue(value, CultureInfo.InvariantCulture)}"; } private void ProcessWhereExpressionRecursive(Expression expression) @@ -291,12 +249,10 @@ private void ProcessWhereExpressionRecursive(Expression expression) ); break; - case ConstantExpression constant when constant.Type == typeof(bool): + case ConstantExpression { Value: bool value } constant + when constant.Type == typeof(bool): // Handle PredicateBuilder.True() and PredicateBuilder.False() constant expressions - var value = (bool)constant.Value!; - _builder.AddWhereCondition( - WhereCondition.FromExpression(value ? "1 = 1" : "1 = 0") - ); + _builder.AddWhereCondition(WhereCondition.FromExpression(BooleanSql(value))); break; } } @@ -307,20 +263,10 @@ private void ProcessBinaryExpression(BinaryExpression binary) { case ExpressionType.AndAlso: // Special handling for constant boolean expressions - if ( - binary.Left is ConstantExpression leftConst - && leftConst.Type == typeof(bool) - && binary.Right is ConstantExpression rightConst - && rightConst.Type == typeof(bool) - ) + var andFolded = TryFoldBooleanConstants(binary, static (l, r) => l && r); + if (andFolded != null) { - // Optimize boolean constants: true AND true = true, false AND anything = false, etc. - var leftValue = (bool)leftConst.Value!; - var rightValue = (bool)rightConst.Value!; - var result = leftValue && rightValue; - _builder.AddWhereCondition( - WhereCondition.FromExpression(result ? "1 = 1" : "1 = 0") - ); + _builder.AddWhereCondition(WhereCondition.FromExpression(andFolded)); } else { @@ -332,20 +278,10 @@ binary.Left is ConstantExpression leftConst case ExpressionType.OrElse: // Special handling for constant boolean expressions to avoid unnecessary parentheses - if ( - binary.Left is ConstantExpression orLeftConst - && orLeftConst.Type == typeof(bool) - && binary.Right is ConstantExpression orRightConst - && orRightConst.Type == typeof(bool) - ) + var orFolded = TryFoldBooleanConstants(binary, static (l, r) => l || r); + if (orFolded != null) { - // Optimize boolean constants: false OR false = false, true OR anything = true, etc. - var leftValue = (bool)orLeftConst.Value!; - var rightValue = (bool)orRightConst.Value!; - var result = leftValue || rightValue; - _builder.AddWhereCondition( - WhereCondition.FromExpression(result ? "1 = 1" : "1 = 0") - ); + _builder.AddWhereCondition(WhereCondition.FromExpression(orFolded)); } else { @@ -372,8 +308,7 @@ binary.Left is ConstantExpression orLeftConst private void AddComparisonCondition(BinaryExpression binary) { - var columnName = ExtractColumnName(binary.Left) ?? ExtractColumnName(binary.Right); - var value = ExtractValue(binary.Right) ?? ExtractValue(binary.Left); + var (columnName, value) = ExtractComparisonOperands(binary); if (columnName == null) return; @@ -381,29 +316,18 @@ private void AddComparisonCondition(BinaryExpression binary) // Handle NULL comparisons specially if (value == null) { - var nullCondition = binary.NodeType switch - { - ExpressionType.Equal => $"{columnName} IS NULL", - ExpressionType.NotEqual => $"{columnName} IS NOT NULL", - _ => $"{columnName} IS NULL", - }; - _builder.AddWhereCondition(WhereCondition.FromExpression(nullCondition)); + _builder.AddWhereCondition( + WhereCondition.FromExpression(NullComparisonSql(columnName, binary.NodeType)) + ); return; } - var op = binary.NodeType switch - { - ExpressionType.Equal => ComparisonOperator.Eq, - ExpressionType.NotEqual => ComparisonOperator.NotEq, - ExpressionType.LessThan => ComparisonOperator.LessThan, - ExpressionType.LessThanOrEqual => ComparisonOperator.LessOrEq, - ExpressionType.GreaterThan => ComparisonOperator.GreaterThan, - ExpressionType.GreaterThanOrEqual => ComparisonOperator.GreaterOrEq, - _ => ComparisonOperator.Eq, - }; - _builder.AddWhereCondition( - WhereCondition.Comparison(ColumnInfo.Named(columnName), op, FormatValue(value)) + WhereCondition.Comparison( + ColumnInfo.Named(columnName), + ComparisonOperatorFor(binary.NodeType), + FormatValue(value, CultureInfo.InvariantCulture) + ) ); } @@ -412,37 +336,31 @@ private void ProcessMethodCallInWhere(MethodCallExpression method) switch (method.Method.Name) { case "Contains" when method.Object != null: - var columnName = ExtractColumnName(method.Object); var value = ExtractValue(method.Arguments[0]); - if (columnName != null && value != null) - { - _builder.AddWhereCondition( - WhereCondition.Comparison( - ColumnInfo.Named(columnName), - ComparisonOperator.Like, - $"%{value}%" - ) - ); - } + AddLikeCondition(ExtractColumnName(method.Object), value, $"%{value}%"); break; case "StartsWith" when method.Object != null: - columnName = ExtractColumnName(method.Object); value = ExtractValue(method.Arguments[0]); - if (columnName != null && value != null) - { - _builder.AddWhereCondition( - WhereCondition.Comparison( - ColumnInfo.Named(columnName), - ComparisonOperator.Like, - $"{value}%" - ) - ); - } + AddLikeCondition(ExtractColumnName(method.Object), value, $"{value}%"); break; } } + private void AddLikeCondition(string? columnName, object? value, string pattern) + { + if (columnName != null && value != null) + { + _builder.AddWhereCondition( + WhereCondition.Comparison( + ColumnInfo.Named(columnName), + ComparisonOperator.Like, + pattern + ) + ); + } + } + private static IEnumerable ExtractColumns(Expression expression) => expression switch { @@ -452,7 +370,7 @@ private static IEnumerable ExtractColumns(Expression expression) => _ => [], }; - private static string? ExtractColumnName(Expression expression) => + internal static string? ExtractColumnName(Expression expression) => expression switch { MemberExpression member => member.Member.Name, @@ -460,40 +378,52 @@ private static IEnumerable ExtractColumns(Expression expression) => _ => null, }; - private static object? ExtractValue(Expression expression) - { - try - { - return expression switch - { - ConstantExpression constant => constant.Value, - _ => Expression.Lambda(expression).Compile().DynamicInvoke(), - }; - } - catch (InvalidOperationException) + // Implements [MIG-AOT-DYNCODE]: walk the subtree to a constant instead of + // Expression.Compile().DynamicInvoke() (IL3050, breaks Native AOT). + internal static object? ExtractValue(Expression expression) => + ConstantExpressionEvaluator.TryEvaluate(expression); + + internal static ComparisonOperator ComparisonOperatorFor(ExpressionType nodeType) => + nodeType switch { - // Expected - expression cannot be evaluated at compile time - return default; - } - catch (ArgumentException) + ExpressionType.NotEqual => ComparisonOperator.NotEq, + ExpressionType.LessThan => ComparisonOperator.LessThan, + ExpressionType.LessThanOrEqual => ComparisonOperator.LessOrEq, + ExpressionType.GreaterThan => ComparisonOperator.GreaterThan, + ExpressionType.GreaterThanOrEqual => ComparisonOperator.GreaterOrEq, + _ => ComparisonOperator.Eq, + }; + + private static string NullComparisonSql(string? columnName, ExpressionType nodeType) => + nodeType switch { - // Expected - invalid expression structure - return default; - } - } + ExpressionType.NotEqual => $"{columnName} IS NOT NULL", + _ => $"{columnName} IS NULL", + }; + + private static (string? ColumnName, object? Value) ExtractComparisonOperands( + BinaryExpression binary + ) => + ( + ExtractColumnName(binary.Left) ?? ExtractColumnName(binary.Right), + ExtractValue(binary.Right) ?? ExtractValue(binary.Left) + ); + + private static string BooleanSql(bool value) => value ? "1 = 1" : "1 = 0"; - private static string FormatValue(object? value) => + // null formatProvider = current culture (LINQ extensions); InvariantCulture = SQL literals (visitor) + internal static string FormatValue(object? value, IFormatProvider? formatProvider) => value switch { null => "NULL", string s => $"'{s.Replace("'", "''", StringComparison.Ordinal)}'", bool b => b ? "1" : "0", - DateTime dt => $"'{dt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)}'", - int i => i.ToString(CultureInfo.InvariantCulture), - long l => l.ToString(CultureInfo.InvariantCulture), - decimal d => d.ToString(CultureInfo.InvariantCulture), - double db => db.ToString(CultureInfo.InvariantCulture), - float f => f.ToString(CultureInfo.InvariantCulture), + DateTime dt => $"'{dt.ToString("yyyy-MM-dd HH:mm:ss", formatProvider)}'", + int i => i.ToString(formatProvider), + long l => l.ToString(formatProvider), + decimal d => d.ToString(formatProvider), + double db => db.ToString(formatProvider), + float f => f.ToString(formatProvider), _ => value.ToString() ?? "NULL", }; } diff --git a/Other/Nimblesite.Sql.Model/WhereConditionSql.cs b/Other/Nimblesite.Sql.Model/WhereConditionSql.cs new file mode 100644 index 00000000..ae3450ae --- /dev/null +++ b/Other/Nimblesite.Sql.Model/WhereConditionSql.cs @@ -0,0 +1,35 @@ +namespace Nimblesite.Sql.Model; + +/// +/// Renders a WHERE condition token sequence for every SQL dialect. +/// Implements [DP-SQL-MODEL-DIALECTS]. +/// +public static class WhereConditionSql +{ + /// + /// Joins formatted WHERE tokens with single spaces. Two adjacent predicates + /// with no explicit logical operator between them are joined with AND. + /// + /// The WHERE condition tokens in order + /// Dialect-specific formatter for a single token + /// The WHERE clause body without the WHERE keyword + public static string Join( + IReadOnlyList conditions, + Func format + ) => + string.Concat( + conditions.Select( + (condition, i) => + (i == 0 ? "" : Separator(conditions[i - 1], condition)) + format(condition) + ) + ); + + private static string Separator(WhereCondition previous, WhereCondition current) => + EndsPredicate(previous) && StartsPredicate(current) ? " AND " : " "; + + private static bool EndsPredicate(WhereCondition condition) => + condition is ComparisonCondition or ExpressionCondition or Parenthesis { IsOpening: false }; + + private static bool StartsPredicate(WhereCondition condition) => + condition is ComparisonCondition or ExpressionCondition or Parenthesis { IsOpening: true }; +} diff --git a/Reporting/Nimblesite.Reporting.Api/GatekeeperBearerAuth.cs b/Reporting/Nimblesite.Reporting.Api/GatekeeperBearerAuth.cs new file mode 100644 index 00000000..7f32753b --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Api/GatekeeperBearerAuth.cs @@ -0,0 +1,108 @@ +using System.Buffers.Text; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Nimblesite.Reporting.Api; + +/// +/// Validates Gatekeeper-issued HS256 bearer tokens locally, without a network call. +/// Implements [REPORT-AUTH-BEARER]. +/// +public static class GatekeeperBearerAuth +{ + private const string Scheme = "Bearer "; + + /// + /// Configuration key holding the base64 Gatekeeper signing key (same key Gatekeeper uses). + /// + public const string SigningKeySetting = "Jwt:SigningKey"; + + /// + /// Reads the signing key from configuration. A missing or malformed key yields an + /// empty key, which rejects every request (fail closed). + /// + /// The configured base64 signing key, if any. + /// The decoded key, or an empty array. + public static ImmutableArray ParseSigningKey(string? base64Key) + { + var buffer = new byte[base64Key?.Length ?? 0]; + return + base64Key is { Length: > 0 } + && Convert.TryFromBase64String(base64Key, buffer, out var written) + ? [.. buffer.AsSpan(0, written)] + : []; + } + + /// + /// True when the Authorization header carries an unexpired token signed with the key. + /// + /// The raw Authorization header value. + /// The Gatekeeper HMAC-SHA256 signing key. + /// The current time, used for the expiry check. + /// Whether the request is authenticated. + public static bool IsAuthorized( + string? authorizationHeader, + ImmutableArray signingKey, + DateTimeOffset now + ) => + !signingKey.IsDefaultOrEmpty + && authorizationHeader is { } header + && header.StartsWith(Scheme, StringComparison.Ordinal) + && IsValidToken(token: header[Scheme.Length..], signingKey: signingKey, now: now); + + private static bool IsValidToken( + string token, + ImmutableArray signingKey, + DateTimeOffset now + ) + { + var parts = token.Split('.'); + return parts.Length == 3 + && CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(Sign(parts[0], parts[1], signingKey)), + Encoding.ASCII.GetBytes(parts[2]) + ) + && HasValidClaims(header: parts[0], payload: parts[1], now: now); + } + + private static string Sign(string header, string payload, ImmutableArray signingKey) => + Base64Url.EncodeToString( + HMACSHA256.HashData(signingKey.AsSpan(), Encoding.UTF8.GetBytes($"{header}.{payload}")) + ); + + private static bool HasValidClaims(string header, string payload, DateTimeOffset now) + { + try + { + using var jose = JsonDocument.Parse(Base64Url.DecodeFromChars(header)); + using var claims = JsonDocument.Parse(Base64Url.DecodeFromChars(payload)); + return HasHs256Algorithm(jose.RootElement) + && HasValidLifetime(claims.RootElement, now.ToUnixTimeSeconds()); + } + catch (Exception ex) when (ex is FormatException or JsonException) + { + return false; + } + } + + private static bool HasHs256Algorithm(JsonElement header) => + header.ValueKind == JsonValueKind.Object + && header.TryGetProperty("alg", out var algorithm) + && algorithm.ValueKind == JsonValueKind.String + && algorithm.GetString() == "HS256"; + + private static bool HasValidLifetime(JsonElement claims, long now) => + claims.ValueKind == JsonValueKind.Object + && claims.TryGetProperty("exp", out var expiry) + && expiry.ValueKind == JsonValueKind.Number + && expiry.TryGetInt64(out var expiresAt) + && expiresAt > now + && ( + !claims.TryGetProperty("nbf", out var notBefore) + || notBefore.ValueKind == JsonValueKind.Number + && notBefore.TryGetInt64(out var startsAt) + && startsAt <= now + ); +} diff --git a/Reporting/Nimblesite.Reporting.Api/Nimblesite.Reporting.Api.csproj b/Reporting/Nimblesite.Reporting.Api/Nimblesite.Reporting.Api.csproj index d075849e..35015856 100644 --- a/Reporting/Nimblesite.Reporting.Api/Nimblesite.Reporting.Api.csproj +++ b/Reporting/Nimblesite.Reporting.Api/Nimblesite.Reporting.Api.csproj @@ -10,11 +10,13 @@ + + diff --git a/Reporting/Nimblesite.Reporting.Api/Program.cs b/Reporting/Nimblesite.Reporting.Api/Program.cs index dcc98373..4ef088b3 100644 --- a/Reporting/Nimblesite.Reporting.Api/Program.cs +++ b/Reporting/Nimblesite.Reporting.Api/Program.cs @@ -1,222 +1,18 @@ -using System.Collections.Immutable; -using System.Data; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.Data.Sqlite; -using Nimblesite.Lql.Core; -using Nimblesite.Lql.SQLite; -using Nimblesite.Reporting.Engine; -using Nimblesite.Sql.Model; -using ConnError = Outcome.Result.Error< - System.Data.IDbConnection, - Nimblesite.Sql.Model.SqlError ->; -using ConnOk = Outcome.Result.Ok< - System.Data.IDbConnection, - Nimblesite.Sql.Model.SqlError ->; -using ConnResult = Outcome.Result; -using EngineError = Outcome.Result< - Nimblesite.Reporting.Engine.ReportExecutionResult, - Nimblesite.Sql.Model.SqlError ->.Error; -using EngineOk = Outcome.Result< - Nimblesite.Reporting.Engine.ReportExecutionResult, - Nimblesite.Sql.Model.SqlError ->.Ok; -using LoadDirError = Outcome.Result< - System.Collections.Immutable.ImmutableArray, - Nimblesite.Sql.Model.SqlError ->.Error< - System.Collections.Immutable.ImmutableArray, - Nimblesite.Sql.Model.SqlError ->; -using LoadDirOk = Outcome.Result< - System.Collections.Immutable.ImmutableArray, - Nimblesite.Sql.Model.SqlError ->.Ok< - System.Collections.Immutable.ImmutableArray, - Nimblesite.Sql.Model.SqlError ->; -using LqlParseError = Outcome.Result< - Nimblesite.Lql.Core.LqlStatement, - Nimblesite.Sql.Model.SqlError ->.Error; -using LqlParseOk = Outcome.Result< - Nimblesite.Lql.Core.LqlStatement, - Nimblesite.Sql.Model.SqlError ->.Ok; -using TranspileError = Outcome.Result.Error< - string, - Nimblesite.Sql.Model.SqlError ->; -using TranspileResult = Outcome.Result; +using Nimblesite.Reporting.Api; var builder = WebApplication.CreateBuilder(args); - -builder.Services.Configure(options => -{ - options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; -}); - -builder.Services.AddCors(options => -{ - options.AddPolicy( - "ReportViewer", - policy => - { - policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); - } - ); -}); +builder.Services.AddReportingApi(); var app = builder.Build(); -app.UseCors("ReportViewer"); +app.UseCors(ReportingApi.CorsPolicy); +app.UseReportingBearerAuth(app.Configuration, app.Logger); // Serve static files for the React renderer app.UseDefaultFiles(); app.UseStaticFiles(); -// Load report definitions from the Reports directory -var reportsDir = - app.Configuration["ReportsDirectory"] ?? Path.Combine(AppContext.BaseDirectory, "Reports"); - -var loadResult = ReportConfigLoader.LoadFromDirectory( - directoryPath: reportsDir, - logger: app.Logger -); - -var reports = loadResult switch -{ - LoadDirOk ok => ok.Value.ToImmutableDictionary(r => r.Id), - LoadDirError => ImmutableDictionary.Empty, -}; - -app.Logger.LogInformation("Loaded {Count} report definitions", reports.Count); - -// Connection registry from config -var connectionStrings = app - .Configuration.GetSection("ConnectionStrings") - .GetChildren() - .ToImmutableDictionary(c => c.Key, c => c.Value ?? ""); - -ConnResult CreateConnection(string connectionRef) -{ - if (!connectionStrings.TryGetValue(connectionRef, out var connStr)) - { - return new ConnError(SqlError.Create($"Connection '{connectionRef}' not found")); - } - - try - { - var connection = new SqliteConnection(connStr); - connection.Open(); - return new ConnOk(connection); - } - catch (Exception ex) - { - return new ConnError(SqlError.FromException(ex)); - } -} - -TranspileResult TranspileLql(string lqlCode) -{ - return LqlStatementConverter.ToStatement(lqlCode) switch - { - LqlParseError stmtErr => new TranspileError(stmtErr.Value), - LqlParseOk stmtOk => stmtOk.Value.ToSQLite(), - }; -} - -static IResult FormatExportResult( - ReportExecutionResult executionResult, - ReportDefinition report, - string? datasource, - string? format -) -{ - var targetDs = datasource ?? report.DataSources.FirstOrDefault()?.Id; - if (targetDs is null || !executionResult.DataSources.TryGetValue(targetDs, out var dsResult)) - { - return Results.NotFound(new { Error = $"Data source '{targetDs}' not found" }); - } - - if (format == "csv") - { - var csv = FormatAdapter.ToCsv(dsResult); - return Results.Text(csv, contentType: "text/csv"); - } - - return Results.Ok(dsResult); -} - -// --- API Endpoints --- - -var reportGroup = app.MapGroup("/api/reports").WithTags("Reports"); - -reportGroup.MapGet( - "/", - () => Results.Ok(reports.Values.Select(ReportMetadataMapper.ToMetadata).ToImmutableArray()) -); - -reportGroup.MapGet( - "/{id}", - (string id) => - reports.TryGetValue(id, out var report) - ? Results.Ok(ReportMetadataMapper.ToMetadata(report)) - : Results.NotFound(new { Error = $"Report '{id}' not found" }) -); - -reportGroup.MapPost( - "/{id}/execute", - (string id, ReportExecuteRequest request) => - { - if (!reports.TryGetValue(id, out var report)) - { - return Results.NotFound(new { Error = $"Report '{id}' not found" }); - } - - return ReportEngine.Execute( - report: report, - parameters: request.Parameters, - connectionFactory: CreateConnection, - lqlTranspiler: TranspileLql, - logger: app.Logger - ) switch - { - EngineOk ok => Results.Ok(ok.Value), - EngineError err => Results.Problem(err.Value.Message), - }; - } -); - -reportGroup.MapGet( - "/{id}/export", - (string id, string? datasource, string? format) => - { - if (!reports.TryGetValue(id, out var report)) - { - return Results.NotFound(new { Error = $"Report '{id}' not found" }); - } - - return ReportEngine.Execute( - report: report, - parameters: ImmutableDictionary.Empty, - connectionFactory: CreateConnection, - lqlTranspiler: TranspileLql, - logger: app.Logger - ) switch - { - EngineError err => Results.Problem(err.Value.Message), - EngineOk ok => FormatExportResult( - executionResult: ok.Value, - report: report, - datasource: datasource, - format: format - ), - }; - } -); +app.MapReportingApi(app.Configuration, app.Logger); app.Run(); diff --git a/Reporting/Nimblesite.Reporting.Api/ReportingApi.cs b/Reporting/Nimblesite.Reporting.Api/ReportingApi.cs new file mode 100644 index 00000000..02d8b9ca --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Api/ReportingApi.cs @@ -0,0 +1,224 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Http.Json; +using Nimblesite.Reporting.Engine; +using EngineError = Outcome.Result< + Nimblesite.Reporting.Engine.ReportExecutionResult, + Nimblesite.Sql.Model.SqlError +>.Error; +using EngineOk = Outcome.Result< + Nimblesite.Reporting.Engine.ReportExecutionResult, + Nimblesite.Sql.Model.SqlError +>.Ok; +using EngineResult = Outcome.Result< + Nimblesite.Reporting.Engine.ReportExecutionResult, + Nimblesite.Sql.Model.SqlError +>; +using LoadDirError = Outcome.Result< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>; +using LoadDirOk = Outcome.Result< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>.Ok< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.Reporting.Api; + +/// +/// The Reporting HTTP API: report listing, metadata, execution and export, guarded by +/// Gatekeeper bearer authentication. Shared by the host and its E2E tests. +/// Implements [REPORT-SECURITY] and [REPORT-AUTH-BEARER]. +/// +public static class ReportingApi +{ + /// + /// CORS policy name used by the report viewer. + /// + public const string CorsPolicy = "ReportViewer"; + + /// + /// Registers camelCase JSON and the report viewer CORS policy. + /// + /// The service collection. + /// The same service collection. + public static IServiceCollection AddReportingApi(this IServiceCollection services) => + services + .Configure(options => + options.SerializerOptions.PropertyNamingPolicy = System + .Text + .Json + .JsonNamingPolicy + .CamelCase + ) + .AddCors(options => + options.AddPolicy( + CorsPolicy, + policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod() + ) + ); + + /// + /// Rejects every /api request without a valid Gatekeeper bearer token with 401. + /// Must run after CORS (so preflights succeed) and before the endpoints. + /// + /// The application builder. + /// Configuration holding Jwt:SigningKey. + /// Logger for rejected requests. + /// The same application builder. + public static IApplicationBuilder UseReportingBearerAuth( + this IApplicationBuilder app, + IConfiguration configuration, + ILogger logger + ) + { + var signingKey = GatekeeperBearerAuth.ParseSigningKey( + configuration[GatekeeperBearerAuth.SigningKeySetting] + ); + logger.LogInformation( + "Reporting bearer auth signing key: {KeyState}", + signingKey.IsDefaultOrEmpty ? "missing (all API requests rejected)" : "present" + ); + return app.Use(next => + context => + !context.Request.Path.StartsWithSegments("/api", StringComparison.Ordinal) + || GatekeeperBearerAuth.IsAuthorized( + authorizationHeader: context.Request.Headers.Authorization, + signingKey: signingKey, + now: DateTimeOffset.UtcNow + ) + ? next(context) + : Reject(context, logger) + ); + } + + /// + /// Maps the /api/reports endpoints over the configured reports and connections. + /// + /// The endpoint route builder. + /// Configuration with ReportsDirectory and connections. + /// Logger for report loading and execution. + /// The report route group. + public static RouteGroupBuilder MapReportingApi( + this IEndpointRouteBuilder endpoints, + IConfiguration configuration, + ILogger logger + ) + { + var reports = LoadReports(configuration, logger); + var connections = ReportingConnections.FromConfiguration(configuration); + EngineResult Execute(ReportDefinition report, ImmutableDictionary values) => + ReportEngine.Execute( + report: report, + parameters: values, + connectionFactory: connectionRef => + ReportingConnections.Open(connections, connectionRef), + lqlTranspiler: (connectionRef, lql) => + ReportingConnections.Transpile(connections, connectionRef, lql), + logger: logger + ); + var group = endpoints.MapGroup("/api/reports").WithTags("Reports"); + MapEndpoints(group, reports, Execute); + return group; + } + + private static void MapEndpoints( + RouteGroupBuilder group, + ImmutableDictionary reports, + Func, EngineResult> execute + ) + { + group.MapGet( + "/", + () => + Results.Ok( + reports.Values.Select(ReportMetadataMapper.ToMetadata).ToImmutableArray() + ) + ); + group.MapGet( + "/{id}", + (string id) => + reports.TryGetValue(id, out var report) + ? Results.Ok(ReportMetadataMapper.ToMetadata(report)) + : NotFound(id) + ); + group.MapPost( + "/{id}/execute", + (string id, ReportExecuteRequest request) => + reports.TryGetValue(id, out var report) + ? ToResult(execute(report, request.Parameters)) + : NotFound(id) + ); + group.MapGet( + "/{id}/export", + (string id, string? datasource, string? format) => + reports.TryGetValue(id, out var report) + ? Export( + execute(report, ImmutableDictionary.Empty), + report, + datasource, + format + ) + : NotFound(id) + ); + } + + private static Task Reject(HttpContext context, ILogger logger) + { + logger.LogWarning("Rejected unauthenticated reporting API request"); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + + private static ImmutableDictionary LoadReports( + IConfiguration configuration, + ILogger logger + ) + { + var reportsDir = + configuration["ReportsDirectory"] ?? Path.Combine(AppContext.BaseDirectory, "Reports"); + var reports = ReportConfigLoader.LoadFromDirectory( + directoryPath: reportsDir, + logger: logger + ) switch + { + LoadDirOk ok => ok.Value.ToImmutableDictionary(r => r.Id), + LoadDirError => ImmutableDictionary.Empty, + }; + logger.LogInformation("Loaded {Count} report definitions", reports.Count); + return reports; + } + + private static IResult ToResult(EngineResult result) => + result switch + { + EngineOk ok => Results.Ok(ok.Value), + EngineError error => Results.Problem(error.Value.Message), + }; + + private static IResult NotFound(string id) => + Results.NotFound(new { Error = $"Report '{id}' not found" }); + + private static IResult Export( + EngineResult result, + ReportDefinition report, + string? datasource, + string? format + ) + { + if (result is not EngineOk ok) + { + return ToResult(result); + } + var targetDs = datasource ?? report.DataSources.FirstOrDefault()?.Id; + return targetDs is null || !ok.Value.DataSources.TryGetValue(targetDs, out var dsResult) + ? Results.NotFound(new { Error = $"Data source '{targetDs}' not found" }) + : format == "csv" ? Results.Text(FormatAdapter.ToCsv(dsResult), contentType: "text/csv") + : Results.Ok(dsResult); + } +} diff --git a/Reporting/Nimblesite.Reporting.Api/ReportingConnections.cs b/Reporting/Nimblesite.Reporting.Api/ReportingConnections.cs new file mode 100644 index 00000000..5727b5d5 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Api/ReportingConnections.cs @@ -0,0 +1,168 @@ +using System.Collections.Immutable; +using System.Data; +using System.Data.Common; +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Core; +using Nimblesite.Lql.Postgres; +using Nimblesite.Lql.SQLite; +using Nimblesite.Lql.SqlServer; +using Nimblesite.Reporting.Engine; +using Nimblesite.Sql.Model; +using Npgsql; +using ConnError = Outcome.Result.Error< + System.Data.IDbConnection, + Nimblesite.Sql.Model.SqlError +>; +using ConnOk = Outcome.Result.Ok< + System.Data.IDbConnection, + Nimblesite.Sql.Model.SqlError +>; +using ConnResult = Outcome.Result; +using LqlParseError = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Error; +using LqlParseOk = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Ok; +using SqlConnection = Microsoft.Data.SqlClient.SqlConnection; +using TranspileError = Outcome.Result.Error< + string, + Nimblesite.Sql.Model.SqlError +>; +using TranspileResult = Outcome.Result; + +namespace Nimblesite.Reporting.Api; + +/// +/// Server-side connection registry for report data sources. Each connection string is +/// opened with the provider it targets (SQLite, PostgreSQL or SQL Server), and LQL is +/// transpiled for that provider. Implements [REPORT-CONNECTIONS]. +/// +public static class ReportingConnections +{ + private static readonly ImmutableArray SqlServerKeywords = + [ + "Server", + "Initial Catalog", + "User Id", + "Integrated Security", + "TrustServerCertificate", + ]; + + /// + /// Builds the registry from ConnectionStrings:*. ConnectionProviders:{name} + /// (Sqlite, Postgres or SqlServer) overrides provider detection for a connection. + /// + /// Application configuration. + /// Connection configs keyed by connection ref. + public static ImmutableDictionary FromConfiguration( + IConfiguration configuration + ) => + configuration + .GetSection("ConnectionStrings") + .GetChildren() + .ToImmutableDictionary( + section => section.Key, + section => new ConnectionConfig( + Provider: Enum.TryParse( + configuration[$"ConnectionProviders:{section.Key}"], + ignoreCase: true, + out var configured + ) + ? configured + : DetectProvider(section.Value ?? ""), + ConnectionString: section.Value ?? "" + ) + ); + + /// + /// Detects the provider from connection string keywords, parsed with the ADO.NET + /// connection string parser: PostgreSQL-specific keywords take precedence over + /// the shared Server alias; SQL Server keywords are SQL + /// Server, anything else is a SQLite data source. + /// + /// The connection string to inspect. + /// The detected provider. + public static DatabaseProvider DetectProvider(string connectionString) + { + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + return builder.ContainsKey("Host") + || builder.ContainsKey("Username") + || builder.ContainsKey("User Name") + || builder.ContainsKey("Port") + ? DatabaseProvider.Postgres + : SqlServerKeywords.Any(builder.ContainsKey) ? DatabaseProvider.SqlServer + : DatabaseProvider.Sqlite; + } + + /// + /// Opens the connection registered under . + /// + /// The connection registry. + /// The report data source's connection ref. + /// An open connection the caller owns, or an error. + public static ConnResult Open( + ImmutableDictionary connections, + string connectionRef + ) + { + if (!connections.TryGetValue(connectionRef, out var config)) + { + return new ConnError(SqlError.Create($"Connection '{connectionRef}' not found")); + } + return config.Provider switch + { + DatabaseProvider.Postgres => OpenWith(cs => new NpgsqlConnection(cs), config), + DatabaseProvider.SqlServer => OpenWith(cs => new SqlConnection(cs), config), + _ => OpenWith(cs => new SqliteConnection(cs), config), + }; + } + + private static ConnResult OpenWith(Func create, ConnectionConfig config) + { + try + { + var connection = create(config.ConnectionString); + connection.Open(); + return new ConnOk(connection); + } + catch (Exception ex) + { + return new ConnError(SqlError.FromException(ex)); + } + } + + /// + /// Transpiles LQL to SQL for the provider behind . + /// + /// The connection registry. + /// The report data source's connection ref. + /// The LQL query. + /// Provider SQL, or an error. + public static TranspileResult Transpile( + ImmutableDictionary connections, + string connectionRef, + string lqlCode + ) => + LqlStatementConverter.ToStatement(lqlCode) switch + { + LqlParseError error => new TranspileError(error.Value), + LqlParseOk ok => connections.TryGetValue(connectionRef, out var config) + ? ToProviderSql(ok.Value, config.Provider) + : new TranspileError(SqlError.Create($"Connection '{connectionRef}' not found")), + }; + + private static TranspileResult ToProviderSql( + LqlStatement statement, + DatabaseProvider provider + ) => + provider switch + { + DatabaseProvider.Postgres => statement.ToPostgreSql(), + DatabaseProvider.SqlServer => statement.ToSqlServer(), + DatabaseProvider.Sqlite => statement.ToSQLite(), + _ => new TranspileError(SqlError.Create($"Unsupported provider: {provider}")), + }; +} diff --git a/Reporting/Nimblesite.Reporting.Engine/FormatAdapter.cs b/Reporting/Nimblesite.Reporting.Engine/FormatAdapter.cs index 9afe3df8..938332b1 100644 --- a/Reporting/Nimblesite.Reporting.Engine/FormatAdapter.cs +++ b/Reporting/Nimblesite.Reporting.Engine/FormatAdapter.cs @@ -30,25 +30,27 @@ public static string ToCsv(DataSourceResult result) { var lines = new List(capacity: result.TotalRows + 1) { - string.Join(",", result.ColumnNames), + string.Join(",", result.ColumnNames.Select(EscapeCsvValue)), }; foreach (var row in result.Rows) { - lines.Add(string.Join(",", row.Select(EscapeCsvValue))); + lines.Add(string.Join(",", row.Select(value => EscapeCsvValue(value?.ToString())))); } return string.Join("\n", lines); } - private static string EscapeCsvValue(object? value) + /// + /// Escapes a single CSV field per RFC 4180: fields containing commas, + /// quotes, or newlines are wrapped in double quotes and embedded + /// quotes are doubled. + /// + /// The raw field value. + /// The escaped CSV field. + private static string EscapeCsvValue(string? value) { - if (value is null) - { - return ""; - } - - var str = value.ToString() ?? ""; + var str = value ?? ""; if ( str.Contains(',', StringComparison.Ordinal) || str.Contains('"', StringComparison.Ordinal) diff --git a/Reporting/Nimblesite.Reporting.Engine/ReportEngine.cs b/Reporting/Nimblesite.Reporting.Engine/ReportEngine.cs index b30190cd..6a59578a 100644 --- a/Reporting/Nimblesite.Reporting.Engine/ReportEngine.cs +++ b/Reporting/Nimblesite.Reporting.Engine/ReportEngine.cs @@ -37,6 +37,31 @@ public static EngineResult Execute( Func> connectionFactory, Func> lqlTranspiler, ILogger logger + ) => + Execute( + report: report, + parameters: parameters, + connectionFactory: connectionFactory, + lqlTranspiler: (_, lql) => lqlTranspiler(lql), + logger: logger + ); + + /// + /// Executes all data sources in a report definition, transpiling LQL for the + /// provider behind each data source's connection ref. + /// + /// The report definition to execute. + /// Parameter values provided by the user. + /// Factory that creates open IDbConnection from a connection ref name. + /// Transpiles LQL (second argument) to SQL for the connection ref (first argument). + /// Logger for diagnostics. + /// Result containing all data source results or an error. + public static EngineResult Execute( + ReportDefinition report, + ImmutableDictionary parameters, + Func> connectionFactory, + Func> lqlTranspiler, + ILogger logger ) { logger.LogInformation( @@ -99,7 +124,7 @@ internal static DsResult ExecuteDataSource( DataSourceDefinition dataSource, ImmutableDictionary parameters, Func> connectionFactory, - Func> lqlTranspiler, + Func> lqlTranspiler, ILogger logger ) { @@ -159,7 +184,7 @@ private static DsResult ExecuteLql( DataSourceDefinition dataSource, ImmutableDictionary parameters, Func> connectionFactory, - Func> lqlTranspiler, + Func> lqlTranspiler, ILogger logger ) { @@ -173,7 +198,7 @@ ILogger logger return new DsError(SqlError.Create("LQL data source has no connection reference")); } - return lqlTranspiler(dataSource.Query) switch + return lqlTranspiler(dataSource.ConnectionRef, dataSource.Query) switch { TranspileError transpileErr => new DsError(transpileErr.Value), TranspileOk transpileOk => ExecuteTranspiledSql( @@ -219,9 +244,11 @@ private static DsResult ExecuteQueryOnConnection( ILogger logger ) { + // The factory hands over a freshly opened connection; the engine owns it. + using var owned = connection; try { - using var command = connection.CreateCommand(); + using var command = owned.CreateCommand(); command.CommandText = sql; foreach (var paramName in parameterNames) diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/GatekeeperTestTokens.cs b/Reporting/Nimblesite.Reporting.Integration.Tests/GatekeeperTestTokens.cs new file mode 100644 index 00000000..6b320e45 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/GatekeeperTestTokens.cs @@ -0,0 +1,51 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Nimblesite.Reporting.Integration.Tests; + +/// +/// Mints Gatekeeper-format HS256 bearer tokens for the E2E hosts ([REPORT-AUTH-BEARER]). +/// Deliberately independent of the API's validator so the tests do not validate the +/// validator with itself. +/// +public static class GatekeeperTestTokens +{ + /// + /// Base64 signing key configured on every test API host. + /// + public static readonly string SigningKey = Convert.ToBase64String( + Encoding.UTF8.GetBytes("reporting-e2e-gatekeeper-signing-key-32b") + ); + + /// + /// Creates a token signed with that expires in one hour. + /// + public static string Create() + { + var expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); + return Create( + $$"""{"sub":"reporting-e2e","jti":"{{Guid.NewGuid():N}}","roles":[],"exp":{{expires}}}""" + ); + } + + /// Signs explicit JSON for authentication boundary tests. + /// The JWT claims, including deliberately malformed test values. + /// The JOSE header to sign. + /// A token independently signed with the test host key. + public static string Create( + string claims, + string headerJson = """{"alg":"HS256","typ":"JWT"}""" + ) + { + var header = Encode(headerJson); + var payload = Encode(claims); + using var hmac = new HMACSHA256(Convert.FromBase64String(SigningKey)); + var signature = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{header}.{payload}")); + return $"{header}.{payload}.{Base64Url(signature)}"; + } + + private static string Encode(string json) => Base64Url(Encoding.UTF8.GetBytes(json)); + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj b/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj index 00215786..ba6708a1 100644 --- a/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj @@ -20,6 +20,10 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -31,6 +35,7 @@ + @@ -48,10 +53,26 @@ + + + + + + + +# Security Policy + +GitHub surfaces this policy on the repository's **Security** tab and on the +"Report a vulnerability" page. References: +- Add a security policy: https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/add-security-policy +- Configure private vulnerability reporting: https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/configure-vulnerability-reporting/configure-for-a-repository + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, +discussions, or pull requests.** + +Report privately through GitHub's **private vulnerability reporting**: go to the +repository's **Security** tab → **Report a vulnerability** (or +). This opens +a private, structured advisory only the maintainers can see. + +If you cannot use that channel, email **cftools@nimblesite.co**. + +When reporting, please include: + +- The type of issue (e.g. SQL injection, path traversal, auth bypass, secret exposure). +- The affected version(s), component (DataProvider, LQL, Migration, Sync, Gatekeeper, Reporting), file(s), and any relevant configuration. +- Steps to reproduce, ideally a minimal proof of concept. +- The impact: what an attacker can achieve. + +## What to Expect + +- **Acknowledgement** within **3 business days**. +- An assessment and a remediation plan (or a reasoned decline) within **10 business days**. +- Coordinated disclosure: we will agree a disclosure timeline with you and credit + you in the advisory unless you prefer to remain anonymous. + +## Supported Versions + +DataProvider is currently in **beta**. Security fixes land on the latest released +`0.9.x` beta line; earlier prereleases are not supported. + +| Version | Supported | +| ------------- | --------- | +| 0.9.x (beta) | ✅ | +| < 0.9 (beta) | ❌ | diff --git a/Sync/Nimblesite.Sync.Core/LqlExpressionEvaluator.cs b/Sync/Nimblesite.Sync.Core/LqlExpressionEvaluator.cs index 11cafa7f..d28f8f28 100644 --- a/Sync/Nimblesite.Sync.Core/LqlExpressionEvaluator.cs +++ b/Sync/Nimblesite.Sync.Core/LqlExpressionEvaluator.cs @@ -261,16 +261,7 @@ private static string ResolveArgument(string arg, JsonElement source) if (source.TryGetProperty(columnName, out var prop)) { - return prop.ValueKind switch - { - JsonValueKind.String => prop.GetString(), - JsonValueKind.Number when prop.TryGetInt64(out var l) => l, - JsonValueKind.Number => prop.GetDouble(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => prop.GetRawText(), - }; + return ConvertJsonElement(prop); } // Try case-insensitive match @@ -278,22 +269,28 @@ JsonValueKind.Number when prop.TryGetInt64(out var l) => l, { if (property.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)) { - return property.Value.ValueKind switch - { - JsonValueKind.String => property.Value.GetString(), - JsonValueKind.Number when property.Value.TryGetInt64(out var l) => l, - JsonValueKind.Number => property.Value.GetDouble(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => property.Value.GetRawText(), - }; + return ConvertJsonElement(property.Value); } } return null; } + /// + /// Converts a JSON element to its CLR value. + /// + private static object? ConvertJsonElement(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var l) => l, + JsonValueKind.Number => element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.GetRawText(), + }; + /// /// Parses function arguments, handling quoted strings and nested calls. /// diff --git a/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncBatchTests.cs b/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncBatchTests.cs new file mode 100644 index 00000000..a13debdd --- /dev/null +++ b/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncBatchTests.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; + +namespace Nimblesite.Sync.Http.Tests; + +public sealed partial class CrossDatabaseSyncTests +{ + [Fact] + public void BatchSync_LargeDataset() + { + var sqliteOriginId = Guid.NewGuid().ToString(); + using var sqlite = CreateSqliteDb(sqliteOriginId); + using var postgres = CreatePostgresDb(Guid.NewGuid().ToString()); + + SeedBatchRows(sqlite, recordCount: 100); + AssertBatchTransfer(sqlite, postgres, sqliteOriginId, recordCount: 100, batchSize: 25); + AssertTransferredRows(postgres, recordCount: 100); + } + + private static void SeedBatchRows(SqliteConnection sqlite, int recordCount) + { + for (var i = 0; i < recordCount; i++) + { + using var command = sqlite.CreateCommand(); + command.CommandText = + $"INSERT INTO Person (Id, Name, Email) VALUES ('batch{i}', 'Person {i}', 'p{i}@example.com')"; + Assert.Equal(1, command.ExecuteNonQuery()); + } + } + + private void AssertBatchTransfer( + SqliteConnection sqlite, + NpgsqlConnection postgres, + string originId, + int recordCount, + int batchSize + ) + { + var fromVersion = 0L; + var totalSynced = 0; + while (true) + { + var result = SyncLogRepository.FetchChanges(sqlite, fromVersion, batchSize); + var batch = Assert.IsType(result).Value; + if (batch.Count == 0) + { + break; + } + Assert.Equal(batchSize, batch.Count); + Assert.Equal(fromVersion + 1, batch[0].Version); + ApplyBatch(postgres, batch, originId); + totalSynced += batch.Count; + fromVersion = batch[^1].Version; + } + Assert.Equal(recordCount, totalSynced); + Assert.Equal(recordCount, fromVersion); + } + + private void ApplyBatch( + NpgsqlConnection postgres, + IReadOnlyList batch, + string originId + ) + { + PostgresSyncSession.EnableSuppression(postgres); + foreach (var entry in batch) + { + Assert.Equal("Person", entry.TableName); + Assert.Equal(SyncOperation.Insert, entry.Operation); + Assert.Equal(originId, entry.Origin); + Assert.IsType(PostgresChangeApplier.ApplyChange(postgres, entry, _logger)); + } + PostgresSyncSession.DisableSuppression(postgres); + } + + private static void AssertTransferredRows(NpgsqlConnection postgres, int recordCount) + { + using var command = postgres.CreateCommand(); + command.CommandText = "SELECT id, name, email FROM person"; + using var reader = command.ExecuteReader(); + var ids = ImmutableHashSet.CreateBuilder(); + while (reader.Read()) + { + var id = reader.GetString(0); + Assert.StartsWith("batch", id, StringComparison.Ordinal); + Assert.True(int.TryParse(id.AsSpan(5), out var number)); + Assert.InRange(number, 0, recordCount - 1); + Assert.True(ids.Add(id), $"Duplicate Person {id}"); + Assert.Equal($"Person {number}", reader.GetString(1)); + Assert.Equal($"p{number}@example.com", reader.GetString(2)); + } + Assert.Equal(recordCount, ids.Count); + } +} diff --git a/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncTests.cs b/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncTests.cs index 90f36fed..681373db 100644 --- a/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncTests.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/CrossDatabaseSyncTests.cs @@ -7,7 +7,8 @@ namespace Nimblesite.Sync.Http.Tests; /// [Collection(PostgresTestSuite.Name)] [Trait("Category", "Docker")] -public sealed class CrossDatabaseSyncTests(PostgresContainerFixture fixture) : IAsyncLifetime +public sealed partial class CrossDatabaseSyncTests(PostgresContainerFixture fixture) + : IAsyncLifetime { private string _postgresConnectionString = null!; private readonly ILogger _logger = NullLogger.Instance; @@ -417,62 +418,6 @@ public void TriggerSuppression_PreventsEcho() Assert.Single(list2); } - [Fact] - public void BatchSync_LargeDataset() - { - // Arrange - var sqliteOriginId = Guid.NewGuid().ToString(); - var postgresOriginId = Guid.NewGuid().ToString(); - - using var sqlite = CreateSqliteDb(sqliteOriginId); - using var postgres = CreatePostgresDb(postgresOriginId); - - // Insert 100 records in SQLite - var recordCount = 100; - for (var i = 0; i < recordCount; i++) - { - using var cmd = sqlite.CreateCommand(); - cmd.CommandText = - $"INSERT INTO Person (Id, Name, Email) VALUES ('batch{i}', 'Person {i}', 'p{i}@example.com')"; - cmd.ExecuteNonQuery(); - } - - // Sync in batches - var batchSize = 25; - var fromVersion = 0L; - var totalSynced = 0; - - while (true) - { - var batch = SyncLogRepository.FetchChanges(sqlite, fromVersion, batchSize); - var batchList = ((SyncLogListOk)batch).Value; - - if (batchList.Count == 0) - break; - - PostgresSyncSession.EnableSuppression(postgres); - foreach (var entry in batchList) - { - PostgresChangeApplier.ApplyChange(postgres, entry, _logger); - totalSynced++; - } - PostgresSyncSession.DisableSuppression(postgres); - - fromVersion = batchList.Max(e => e.Version); - } - - // Assert - Assert.Equal(recordCount, totalSynced); - - using var countCmd = postgres.CreateCommand(); - countCmd.CommandText = "SELECT COUNT(*) FROM person"; - var pgCount = Convert.ToInt32( - countCmd.ExecuteScalar(), - System.Globalization.CultureInfo.InvariantCulture - ); - Assert.Equal(recordCount, pgCount); - } - [Fact] public void HashVerification_ConsistentAcrossDatabases() { diff --git a/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointPlatformTests.cs b/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointPlatformTests.cs new file mode 100644 index 00000000..73f73715 --- /dev/null +++ b/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointPlatformTests.cs @@ -0,0 +1,260 @@ +using System.Data.Common; +using System.Net; +using System.Text.Json; +using Microsoft.Data.SqlClient; + +namespace Nimblesite.Sync.Http.Tests; + +[Collection(PostgresTestSuite.Name)] +public sealed record HttpEndpointPlatformTests + : IClassFixture, + IClassFixture +{ + private readonly SyncApiWebApplicationFactory _factory; + private readonly PostgresContainerFixture _postgres; + private readonly HttpSqlServerContainerFixture _sqlServer; + + public HttpEndpointPlatformTests( + SyncApiWebApplicationFactory factory, + PostgresContainerFixture postgres, + HttpSqlServerContainerFixture sqlServer + ) + { + _factory = factory; + _postgres = postgres; + _sqlServer = sqlServer; + } + + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + // Implements [SYNC-HTTP-PROVIDERS]. + public async Task HttpStateAndPull_MatchProviderSupport(string provider) + { + var target = await CreateTargetAsync(provider).ConfigureAwait(true); + try + { + using var client = _factory.CreateClient(); + var query = + $"dbType={provider}&connectionString={Uri.EscapeDataString(target.ConnectionString)}"; + using var state = await client.GetAsync($"/sync/state?{query}").ConfigureAwait(true); + if (provider == "sqlserver") + { + await AssertUnsupportedProviderAsync(state, target.ConnectionString) + .ConfigureAwait(true); + using var rejectedPull = await client + .GetAsync($"/sync/changes?fromVersion=0&batchSize=10&{query}") + .ConfigureAwait(true); + await AssertUnsupportedProviderAsync(rejectedPull, target.ConnectionString) + .ConfigureAwait(true); + return; + } + Assert.Equal(HttpStatusCode.OK, state.StatusCode); + Assert.Equal("application/json", state.Content.Headers.ContentType?.MediaType); + using var stateJson = JsonDocument.Parse( + await state.Content.ReadAsStringAsync().ConfigureAwait(true) + ); + Assert.Equal(0L, stateJson.RootElement.GetProperty("maxVersion").GetInt64()); + + using var pull = await client + .GetAsync($"/sync/changes?fromVersion=0&batchSize=10&{query}") + .ConfigureAwait(true); + Assert.Equal(HttpStatusCode.OK, pull.StatusCode); + Assert.Equal("application/json", pull.Content.Headers.ContentType?.MediaType); + using var pullJson = JsonDocument.Parse( + await pull.Content.ReadAsStringAsync().ConfigureAwait(true) + ); + Assert.Empty(pullJson.RootElement.GetProperty("changes").EnumerateArray()); + } + finally + { + if (target.FilePath is not null) + { + File.Delete(target.FilePath); + } + } + } + + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + // Implements [SYNC-HTTP-PROVIDERS]. + public async Task HttpPush_AppliesSupportedProviderOrRejectsWithoutMutation(string provider) + { + var target = await CreateTargetAsync(provider).ConfigureAwait(true); + try + { + using var client = _factory.CreateClient(); + var id = Guid.Parse("47cb994e-3e7e-492b-9bc1-2e56dc556dcc"); + var change = new + { + Version = 1L, + TableName = "person", + PkValue = JsonSerializer.Serialize(new { id }), + Operation = "insert", + Payload = JsonSerializer.Serialize( + new + { + id, + name = "Ada", + email = "ada@example.test", + } + ), + Origin = "remote-origin", + Timestamp = "2024-01-01T00:00:00Z", + }; + var request = new { Changes = new[] { change }, OriginId = "server-origin" }; + using var content = new StringContent( + JsonSerializer.Serialize(request), + System.Text.Encoding.UTF8, + "application/json" + ); + using var response = await client.PostAsync( + $"/sync/changes?dbType={provider}&connectionString={Uri.EscapeDataString(target.ConnectionString)}", + content + ); + if (provider == "sqlserver") + { + await AssertUnsupportedProviderAsync(response, target.ConnectionString) + .ConfigureAwait(true); + return; + } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + await AssertPersonAsync(provider, target.ConnectionString, id).ConfigureAwait(true); + } + finally + { + if (target.FilePath is not null) + { + File.Delete(target.FilePath); + } + } + } + + private static async Task AssertUnsupportedProviderAsync( + HttpResponseMessage response, + string connectionString + ) + { + // Current API contract uses ProblemDetails/500 for unsupported providers. + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + using var problem = JsonDocument.Parse( + await response.Content.ReadAsStringAsync().ConfigureAwait(false) + ); + Assert.Equal(500, problem.RootElement.GetProperty("status").GetInt32()); + Assert.Equal( + "Unknown database type: sqlserver", + problem.RootElement.GetProperty("detail").GetString() + ); + AssertSqlServerCatalogIsEmpty(connectionString); + } + + private static void AssertSqlServerCatalogIsEmpty(string connectionString) + { + using var connection = new SqlConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sys.objects WHERE is_ms_shipped = 0"; + Assert.Equal(0, Assert.IsType(command.ExecuteScalar())); + } + + private static async Task AssertPersonAsync(string provider, string connectionString, Guid id) + { + using DbConnection connection = provider switch + { + "sqlite" => new SqliteConnection(connectionString), + "postgres" => new NpgsqlConnection(connectionString), + _ => new SqlConnection(connectionString), + }; + await connection.OpenAsync().ConfigureAwait(false); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, name, email FROM person"; + using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + Assert.True(reader.Read(), $"{provider} did not receive the pushed person"); + Assert.Equal(id, Guid.Parse(reader.GetValue(0).ToString() ?? string.Empty)); + Assert.Equal("Ada", reader.GetString(1)); + Assert.Equal("ada@example.test", reader.GetString(2)); + Assert.False(reader.Read(), $"{provider} inserted more than one person"); + } + + private async Task CreateTargetAsync(string provider) + { + if (provider == "postgres") + { + using var connection = await _postgres + .CreateDatabaseAsync("sync_http") + .ConfigureAwait(false); + CreatePersonTable("postgres", connection.ConnectionString, "public"); + Assert.IsType(PostgresSyncSchema.CreateSchema(connection)); + Assert.IsType(PostgresSyncSchema.SetOriginId(connection, "server-origin")); + return new HttpTarget(connection.ConnectionString, FilePath: null); + } + if (provider == "sqlserver") + { + var connectionString = await _sqlServer + .CreateDatabaseConnectionStringAsync() + .ConfigureAwait(false); + AssertSqlServerCatalogIsEmpty(connectionString); + return new HttpTarget(connectionString, FilePath: null); + } + Assert.Equal("sqlite", provider); + var path = Path.Combine(Path.GetTempPath(), $"sync_http_{Guid.NewGuid():N}.db"); + var sqliteConnectionString = $"Data Source={path};Pooling=False"; + CreatePersonTable("sqlite", path, "main"); + using var sqlite = new SqliteConnection(sqliteConnectionString); + await sqlite.OpenAsync().ConfigureAwait(false); + Assert.IsType(SyncSchema.CreateSchema(sqlite)); + Assert.IsType(SyncSchema.SetOriginId(sqlite, "server-origin")); + return new HttpTarget(sqliteConnectionString, path); + } + + private static void CreatePersonTable(string provider, string output, string schema) + { + var yaml = $$""" + name: sync_http_person + tables: + - name: person + schema: {{schema}} + columns: + - name: id + type: Uuid + isNullable: false + - name: name + type: Text + isNullable: false + - name: email + type: Text + isNullable: false + primaryKey: + columns: + - id + """; + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, yaml); + Assert.Equal( + 0, + DataProviderMigrate.Program.Main([ + "migrate", + "--schema", + path, + "--provider", + provider, + "--output", + output, + ]) + ); + } + finally + { + File.Delete(path); + } + } + + private sealed record HttpTarget(string ConnectionString, string? FilePath); +} diff --git a/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointTests.cs b/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointTests.cs index 3b7daff1..e71c135b 100644 --- a/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointTests.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/HttpEndpointTests.cs @@ -51,12 +51,15 @@ public async Task Health_ReturnsOk() #region Pull Changes Endpoint - [Fact] - public async Task PullChanges_WithoutConnectionString_ReturnsBadRequest() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task PullChanges_WithoutConnectionString_ReturnsBadRequest(string provider) { // Act var response = await _client.GetAsync( - "/sync/changes?fromVersion=0&batchSize=100&dbType=sqlite" + $"/sync/changes?fromVersion=0&batchSize=100&dbType={provider}" ); // Assert @@ -79,15 +82,18 @@ public async Task PullChanges_WithInvalidDbType_ReturnsProblem() #region Push Changes Endpoint - [Fact] - public async Task PushChanges_WithoutBody_ReturnsBadRequest() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task PushChanges_WithoutBody_ReturnsBadRequest(string provider) { // Arrange var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json"); // Act var response = await _client.PostAsync( - "/sync/changes?dbType=sqlite&connectionString=test", + $"/sync/changes?dbType={provider}&connectionString=test", content ); @@ -137,15 +143,18 @@ public async Task PushChanges_WithTooManyChanges_ReturnsBadRequest() #region Register Client Endpoint - [Fact] - public async Task RegisterClient_WithoutOriginId_ReturnsBadRequest() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task RegisterClient_WithoutOriginId_ReturnsBadRequest(string provider) { // Arrange var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json"); // Act var response = await _client.PostAsync( - "/sync/clients?dbType=sqlite&connectionString=test", + $"/sync/clients?dbType={provider}&connectionString=test", content ); @@ -153,8 +162,11 @@ public async Task RegisterClient_WithoutOriginId_ReturnsBadRequest() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Fact] - public async Task RegisterClient_WithoutConnectionString_ReturnsBadRequest() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task RegisterClient_WithoutConnectionString_ReturnsBadRequest(string provider) { // Arrange var request = new { OriginId = Guid.NewGuid().ToString(), LastSyncVersion = 0L }; @@ -165,7 +177,7 @@ public async Task RegisterClient_WithoutConnectionString_ReturnsBadRequest() ); // Act - var response = await _client.PostAsync("/sync/clients?dbType=sqlite", content); + var response = await _client.PostAsync($"/sync/clients?dbType={provider}", content); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -175,11 +187,14 @@ public async Task RegisterClient_WithoutConnectionString_ReturnsBadRequest() #region Sync State Endpoint - [Fact] - public async Task SyncState_WithoutConnectionString_ReturnsBadRequest() + [Theory] + [InlineData("sqlite")] + [InlineData("postgres")] + [InlineData("sqlserver")] + public async Task SyncState_WithoutConnectionString_ReturnsBadRequest(string provider) { // Act - var response = await _client.GetAsync("/sync/state?dbType=sqlite"); + var response = await _client.GetAsync($"/sync/state?dbType={provider}"); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -359,336 +374,3 @@ public void ApplyChange_WithSuppression_DoesNotEcho() Assert.Equal("Incoming", cmd.ExecuteScalar()?.ToString()); } } - -#pragma warning disable CA2213 // HttpClient managed by WebApplicationFactory - do not dispose - -/// -/// E2E tests proving REAL sync over HTTP works. -/// Uses WebApplicationFactory for real ASP.NET Core server + real SQLite databases. -/// This is the PROOF that Nimblesite.Sync.Http extension methods work in a real scenario. -/// -public sealed class HttpSyncE2ETests : IClassFixture, IDisposable -{ - private readonly HttpClient _client; - private readonly string _serverDbPath; - private readonly SqliteConnection _serverConn; - private readonly string _clientDbPath; - private readonly SqliteConnection _clientConn; - private readonly string _serverOriginId = Guid.NewGuid().ToString(); - private readonly string _clientOriginId = Guid.NewGuid().ToString(); - - public HttpSyncE2ETests(SyncApiWebApplicationFactory factory) - { - _client = factory.CreateClient(); - - // Create server database - _serverDbPath = Path.Combine(Path.GetTempPath(), $"sync_server_{Guid.NewGuid()}.db"); - _serverConn = new SqliteConnection($"Data Source={_serverDbPath}"); - _serverConn.Open(); - InitializeDatabase(_serverConn, _serverOriginId); - - // Create client database - _clientDbPath = Path.Combine(Path.GetTempPath(), $"sync_client_{Guid.NewGuid()}.db"); - _clientConn = new SqliteConnection($"Data Source={_clientDbPath}"); - _clientConn.Open(); - InitializeDatabase(_clientConn, _clientOriginId); - } - - private static void InitializeDatabase(SqliteConnection conn, string originId) - { - SyncSchema.CreateSchema(conn); - SyncSchema.SetOriginId(conn, originId); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - CREATE TABLE Person ( - Id TEXT PRIMARY KEY, - Name TEXT NOT NULL, - Email TEXT - ); - """; - cmd.ExecuteNonQuery(); - - TriggerGenerator.CreateTriggers(conn, "Person", NullLogger.Instance); - } - - public void Dispose() - { - _client.Dispose(); - _serverConn.Close(); - _serverConn.Dispose(); - _clientConn.Close(); - _clientConn.Dispose(); - - try - { - if (File.Exists(_serverDbPath)) - File.Delete(_serverDbPath); - if (File.Exists(_clientDbPath)) - File.Delete(_clientDbPath); - } - catch - { - // Ignore cleanup errors - } - } - - /// - /// Tests that changes inserted on client can be pushed via HTTP and appear on server. - /// - [Fact] - public async Task PushChanges_InsertsOnClient_AppearsOnServer() - { - // Arrange - insert data on client - using (var cmd = _clientConn.CreateCommand()) - { - cmd.CommandText = - "INSERT INTO Person (Id, Name, Email) VALUES ('http1', 'HTTP Test', 'http@test.com')"; - cmd.ExecuteNonQuery(); - } - - // Get changes from client - var clientChanges = SyncLogRepository.FetchChanges(_clientConn, 0, 100); - Assert.True(clientChanges is SyncLogListOk); - var changesList = ((SyncLogListOk)clientChanges).Value; - Assert.Single(changesList); - - // Build request - OriginId is the origin to SKIP (server's own origin prevents echo) - // We're pushing FROM client, so server should NOT skip client's changes - var pushRequest = new - { - Changes = changesList - .Select(c => new - { - c.Version, - c.TableName, - c.PkValue, - Operation = c.Operation.ToString().ToLowerInvariant(), - c.Payload, - c.Origin, - c.Timestamp, - }) - .ToList(), - OriginId = _serverOriginId, - }; - - var content = new StringContent( - JsonSerializer.Serialize(pushRequest), - System.Text.Encoding.UTF8, - "application/json" - ); - - var connStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); - - // Act - push changes via HTTP - var response = await _client.PostAsync( - $"/sync/changes?dbType=sqlite&connectionString={connStr}", - content - ); - - // Assert - HTTP success - var body = await response.Content.ReadAsStringAsync(); - Assert.True(response.IsSuccessStatusCode, $"Push failed: {response.StatusCode} - {body}"); - - // Verify data appears on server - using var verifyCmd = _serverConn.CreateCommand(); - verifyCmd.CommandText = "SELECT Name FROM Person WHERE Id = 'http1'"; - var name = verifyCmd.ExecuteScalar()?.ToString(); - Assert.Equal("HTTP Test", name); - } - - /// - /// Tests that changes on server can be pulled via HTTP and applied to client. - /// - [Fact] - public async Task PullChanges_ChangesOnServer_AppearsOnClient() - { - // Arrange - insert data on server - using (var cmd = _serverConn.CreateCommand()) - { - cmd.CommandText = - "INSERT INTO Person (Id, Name, Email) VALUES ('pull1', 'Pull Test', 'pull@test.com')"; - cmd.ExecuteNonQuery(); - } - - var connStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); - - // Act - pull changes via HTTP - var response = await _client.GetAsync( - $"/sync/changes?fromVersion=0&batchSize=100&dbType=sqlite&connectionString={connStr}" - ); - - // Assert - HTTP success - var body = await response.Content.ReadAsStringAsync(); - Assert.True(response.IsSuccessStatusCode, $"Pull failed: {response.StatusCode} - {body}"); - - // Parse response (ASP.NET Core uses camelCase by default) - using var doc = JsonDocument.Parse(body); - var changesArray = doc.RootElement.GetProperty("changes"); - Assert.True(changesArray.GetArrayLength() > 0, "No changes returned"); - - // Apply changes to client (operation is returned as integer enum value) - foreach (var change in changesArray.EnumerateArray()) - { - var entry = new SyncLogEntry( - Version: change.GetProperty("version").GetInt64(), - TableName: change.GetProperty("tableName").GetString() ?? "", - PkValue: change.GetProperty("pkValue").GetString() ?? "", - Operation: (SyncOperation)change.GetProperty("operation").GetInt32(), - Payload: change.GetProperty("payload").GetString() ?? "", - Origin: change.GetProperty("origin").GetString() ?? "", - Timestamp: change.GetProperty("timestamp").GetString() ?? "" - ); - - SyncSessionManager.EnableSuppression(_clientConn); - var result = ChangeApplierSQLite.ApplyChange(_clientConn, entry); - SyncSessionManager.DisableSuppression(_clientConn); - Assert.True(result is BoolSyncOk, $"Apply failed: {result}"); - } - - // Verify data appears on client - using var verifyCmd = _clientConn.CreateCommand(); - verifyCmd.CommandText = "SELECT Name FROM Person WHERE Id = 'pull1'"; - var name = verifyCmd.ExecuteScalar()?.ToString(); - Assert.Equal("Pull Test", name); - } - - /// - /// Tests bidirectional sync - changes go both ways via HTTP. - /// - [Fact] - public async Task BidirectionalSync_ChangesBothWays_BothDatabasesHaveBothRecords() - { - // Arrange - insert different data on each side - using (var cmd = _serverConn.CreateCommand()) - { - cmd.CommandText = - "INSERT INTO Person (Id, Name, Email) VALUES ('bidir_server', 'Server Record', 'server@test.com')"; - cmd.ExecuteNonQuery(); - } - - using (var cmd = _clientConn.CreateCommand()) - { - cmd.CommandText = - "INSERT INTO Person (Id, Name, Email) VALUES ('bidir_client', 'Client Record', 'client@test.com')"; - cmd.ExecuteNonQuery(); - } - - var serverConnStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); - var clientConnStr = Uri.EscapeDataString($"Data Source={_clientDbPath}"); - - // Pull from server -> apply to client - var pullResponse = await _client.GetAsync( - $"/sync/changes?fromVersion=0&batchSize=100&dbType=sqlite&connectionString={serverConnStr}" - ); - Assert.True(pullResponse.IsSuccessStatusCode); - - var pullBody = await pullResponse.Content.ReadAsStringAsync(); - using var pullDoc = JsonDocument.Parse(pullBody); - - foreach (var change in pullDoc.RootElement.GetProperty("changes").EnumerateArray()) - { - var entry = new SyncLogEntry( - Version: change.GetProperty("version").GetInt64(), - TableName: change.GetProperty("tableName").GetString() ?? "", - PkValue: change.GetProperty("pkValue").GetString() ?? "", - Operation: (SyncOperation)change.GetProperty("operation").GetInt32(), - Payload: change.GetProperty("payload").GetString() ?? "", - Origin: change.GetProperty("origin").GetString() ?? "", - Timestamp: change.GetProperty("timestamp").GetString() ?? "" - ); - - if (entry.Origin != _clientOriginId) - { - SyncSessionManager.EnableSuppression(_clientConn); - ChangeApplierSQLite.ApplyChange(_clientConn, entry); - SyncSessionManager.DisableSuppression(_clientConn); - } - } - - // Push from client -> server (OriginId = server's origin to not skip client changes) - var clientChanges = SyncLogRepository.FetchChanges(_clientConn, 0, 100); - var clientChangesList = ((SyncLogListOk)clientChanges).Value; - - var pushRequest = new - { - Changes = clientChangesList - .Select(c => new - { - c.Version, - c.TableName, - c.PkValue, - Operation = c.Operation.ToString().ToLowerInvariant(), - c.Payload, - c.Origin, - c.Timestamp, - }) - .ToList(), - OriginId = _serverOriginId, - }; - - var content = new StringContent( - JsonSerializer.Serialize(pushRequest), - System.Text.Encoding.UTF8, - "application/json" - ); - - var pushResponse = await _client.PostAsync( - $"/sync/changes?dbType=sqlite&connectionString={serverConnStr}", - content - ); - Assert.True(pushResponse.IsSuccessStatusCode); - - // Assert - both databases have both records - using var serverCmd = _serverConn.CreateCommand(); - serverCmd.CommandText = "SELECT COUNT(*) FROM Person"; - Assert.Equal( - 2L, - Convert.ToInt64( - serverCmd.ExecuteScalar(), - System.Globalization.CultureInfo.InvariantCulture - ) - ); - - using var clientCmd = _clientConn.CreateCommand(); - clientCmd.CommandText = "SELECT COUNT(*) FROM Person"; - Assert.Equal( - 2L, - Convert.ToInt64( - clientCmd.ExecuteScalar(), - System.Globalization.CultureInfo.InvariantCulture - ) - ); - } - - /// - /// Tests sync state endpoint returns correct max version. - /// - [Fact] - public async Task SyncState_ReturnsCorrectMaxVersion() - { - // Arrange - insert multiple records - for (var i = 0; i < 5; i++) - { - using var cmd = _serverConn.CreateCommand(); - cmd.CommandText = - $"INSERT INTO Person (Id, Name, Email) VALUES ('state{i}', 'Person {i}', 'p{i}@test.com')"; - cmd.ExecuteNonQuery(); - } - - var connStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); - - // Act - var response = await _client.GetAsync( - $"/sync/state?dbType=sqlite&connectionString={connStr}" - ); - - // Assert - Assert.True(response.IsSuccessStatusCode); - var body = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(body); - - var maxVersion = doc.RootElement.GetProperty("maxVersion").GetInt64(); - Assert.Equal(5L, maxVersion); - } -} diff --git a/Sync/Nimblesite.Sync.Http.Tests/HttpMappingE2ETests.cs b/Sync/Nimblesite.Sync.Http.Tests/HttpMappingE2ETests.cs index c03fc2ba..95697f8e 100644 --- a/Sync/Nimblesite.Sync.Http.Tests/HttpMappingE2ETests.cs +++ b/Sync/Nimblesite.Sync.Http.Tests/HttpMappingE2ETests.cs @@ -153,6 +153,20 @@ private static SyncMappingConfig CreateUserToCustomerMapping() => ] ); + /// + /// Seeds SQLite source + Postgres target with fresh origins and returns the User->Customer mapping. + /// + private SyncMappingConfig SetupUserToCustomerScenario() + { + var sourceOrigin = Guid.NewGuid().ToString(); + var targetOrigin = Guid.NewGuid().ToString(); + + SetupSqliteSource(sourceOrigin); + SetupPostgresTarget(targetOrigin); + + return CreateUserToCustomerMapping(); + } + /// /// Merges the PK value into a payload for insert operations. /// PostgresChangeApplier expects PK to be in the payload. @@ -196,6 +210,67 @@ JsonValueKind.Number when element.TryGetInt64(out var l) => l, _ => element.GetRawText(), }; + /// + /// Shared act step: runs the mapping engine with the class logger (Push by default). + /// + private MappingResult ApplyMapping( + SyncLogEntry entry, + SyncMappingConfig config, + MappingDirection direction = MappingDirection.Push + ) => MappingEngine.ApplyMapping(entry, config, direction, _logger); + + /// + /// Shared arrange step: single-table Push mapping against Source -> target, + /// the shape used by the LQL transform tests. + /// + private static SyncMappingConfig MappingConfig( + string mappingId, + params ColumnMapping[] mappings + ) => MappingConfig(mappingId, "Source", "target", mappings); + + /// Single-table Push mapping with explicit table names. + private static SyncMappingConfig MappingConfig( + string mappingId, + string sourceTable, + string targetTable, + params ColumnMapping[] mappings + ) => + new( + Version: "1.0", + UnmappedTableBehavior: UnmappedTableBehavior.Strict, + Mappings: + [ + new TableMapping( + Id: mappingId, + SourceTable: sourceTable, + TargetTable: targetTable, + Direction: MappingDirection.Push, + Enabled: true, + PkMapping: null, + ColumnMappings: mappings, + ExcludedColumns: [], + Filter: null, + SyncTracking: new SyncTrackingConfig() + ), + ] + ); + + /// Shared LQL transform column mapping. + private static ColumnMapping LqlColumn(string target, string lql) => + new(Source: null, Target: target, Transform: TransformType.Lql, Lql: lql); + + /// Shared insert entry on the Source table (pass a different tableName to override). + private static SyncLogEntry SourceEntry(string payload, string tableName = "Source") => + new( + Version: 1, + TableName: tableName, + PkValue: """{"Id":"1"}""", + Operation: SyncOperation.Insert, + Payload: payload, + Origin: "test", + Timestamp: "2024-01-01T00:00:00Z" + ); + [Fact] public void MappingEngine_TransformsUserToCustomer_WithLql() { @@ -213,7 +288,7 @@ public void MappingEngine_TransformsUserToCustomer_WithLql() var config = CreateUserToCustomerMapping(); // Act - apply mapping - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -251,32 +326,11 @@ public void MappingEngine_TransformsUserToCustomer_WithLql() public void MappingEngine_WithConcatTransform_CombinesColumns() { // Arrange - mapping with concat - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "name-concat", - SourceTable: "Person", - TargetTable: "person", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "full_name", - Transform: TransformType.Lql, - Lql: "concat(FirstName, ' ', LastName)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "name-concat", + sourceTable: "Person", + targetTable: "person", + LqlColumn("full_name", "concat(FirstName, ' ', LastName)") ); var entry = new SyncLogEntry( @@ -290,7 +344,7 @@ public void MappingEngine_WithConcatTransform_CombinesColumns() ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -302,32 +356,11 @@ public void MappingEngine_WithConcatTransform_CombinesColumns() public void MappingEngine_WithCoalesceTransform_ReturnsFirstNonNull() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "coalesce-test", - SourceTable: "Contact", - TargetTable: "contact", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "phone", - Transform: TransformType.Lql, - Lql: "coalesce(Mobile, Home, Office)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "coalesce-test", + sourceTable: "Contact", + targetTable: "contact", + LqlColumn("phone", "coalesce(Mobile, Home, Office)") ); var entry = new SyncLogEntry( @@ -341,7 +374,7 @@ public void MappingEngine_WithCoalesceTransform_ReturnsFirstNonNull() ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -353,32 +386,11 @@ public void MappingEngine_WithCoalesceTransform_ReturnsFirstNonNull() public void MappingEngine_WithSubstringTransform_ExtractsText() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "substring-test", - SourceTable: "Product", - TargetTable: "product", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "sku_prefix", - Transform: TransformType.Lql, - Lql: "substring(SKU, 1, 3)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "substring-test", + sourceTable: "Product", + targetTable: "product", + LqlColumn("sku_prefix", "substring(SKU, 1, 3)") ); var entry = new SyncLogEntry( @@ -392,7 +404,7 @@ public void MappingEngine_WithSubstringTransform_ExtractsText() ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -404,13 +416,7 @@ public void MappingEngine_WithSubstringTransform_ExtractsText() public void E2E_SyncWithMapping_TransformsData_AcrossDatabases() { // Arrange - set up databases with DIFFERENT schemas - var sourceOrigin = Guid.NewGuid().ToString(); - var targetOrigin = Guid.NewGuid().ToString(); - - SetupSqliteSource(sourceOrigin); - SetupPostgresTarget(targetOrigin); - - var config = CreateUserToCustomerMapping(); + var config = SetupUserToCustomerScenario(); // Act - insert User in SQLite source using (var cmd = _sqliteConn.CreateCommand()) @@ -429,12 +435,7 @@ INSERT INTO User (Id, FullName, EmailAddress, PasswordHash, SecurityStamp, Creat Assert.Single(changesList); // Apply mapping to transform the entry - var mappingResult = MappingEngine.ApplyMapping( - changesList[0], - config, - MappingDirection.Push, - _logger - ); + var mappingResult = ApplyMapping(changesList[0], config); var mappedEntry = Assert.IsType(mappingResult); // Apply mapped changes to Postgres with suppression @@ -483,13 +484,7 @@ INSERT INTO User (Id, FullName, EmailAddress, PasswordHash, SecurityStamp, Creat public void E2E_MultipleRecords_AllTransformedCorrectly() { // Arrange - var sourceOrigin = Guid.NewGuid().ToString(); - var targetOrigin = Guid.NewGuid().ToString(); - - SetupSqliteSource(sourceOrigin); - SetupPostgresTarget(targetOrigin); - - var config = CreateUserToCustomerMapping(); + var config = SetupUserToCustomerScenario(); // Insert multiple users var users = new[] @@ -518,12 +513,7 @@ INSERT INTO User (Id, FullName, EmailAddress, PasswordHash, SecurityStamp, Creat PostgresSyncSession.EnableSuppression(_postgresConn); foreach (var change in changesList) { - var mappingResult = MappingEngine.ApplyMapping( - change, - config, - MappingDirection.Push, - _logger - ); + var mappingResult = ApplyMapping(change, config); var mapped = Assert.IsType(mappingResult); foreach (var entry in mapped.Entries) @@ -584,7 +574,7 @@ public void MappingEngine_DeleteOperation_TransformsTableAndPk() ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -613,7 +603,7 @@ public void MappingEngine_UpdateOperation_TransformsPayload() ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -631,46 +621,12 @@ public void MappingEngine_UpdateOperation_TransformsPayload() public void LqlExpression_WithNullValue_ReturnsNull() { // Arrange - payload with null field - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "null-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "upper_name", - Transform: TransformType.Lql, - Lql: "upper(Name)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] - ); + var config = MappingConfig(mappingId: "null-test", LqlColumn("upper_name", "upper(Name)")); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":null}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Name":null}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - should handle null gracefully var success = Assert.IsType(result); @@ -681,46 +637,12 @@ public void LqlExpression_WithNullValue_ReturnsNull() public void LqlExpression_WithEmptyString_ReturnsEmpty() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "empty-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "upper_name", - Transform: TransformType.Lql, - Lql: "upper(Name)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] - ); + var config = MappingConfig(mappingId: "empty-test", LqlColumn("upper_name", "upper(Name)")); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":""}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Name":""}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -732,46 +654,15 @@ public void LqlExpression_WithEmptyString_ReturnsEmpty() public void LqlExpression_ReplaceFunction_WorksCorrectly() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "replace-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "clean_phone", - Transform: TransformType.Lql, - Lql: "replace(Phone, '-', '')" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "replace-test", + LqlColumn("clean_phone", "replace(Phone, '-', '')") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Phone":"555-123-4567"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Phone":"555-123-4567"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -783,46 +674,12 @@ public void LqlExpression_ReplaceFunction_WorksCorrectly() public void LqlExpression_LeftFunction_ExtractsPrefix() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "left-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "initials", - Transform: TransformType.Lql, - Lql: "left(Name, 2)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] - ); + var config = MappingConfig(mappingId: "left-test", LqlColumn("initials", "left(Name, 2)")); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":"Alexander"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Name":"Alexander"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -834,46 +691,15 @@ public void LqlExpression_LeftFunction_ExtractsPrefix() public void LqlExpression_RightFunction_ExtractsSuffix() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "right-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "last_four", - Transform: TransformType.Lql, - Lql: "right(CardNumber, 4)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "right-test", + LqlColumn("last_four", "right(CardNumber, 4)") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","CardNumber":"1234567890123456"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","CardNumber":"1234567890123456"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -885,46 +711,12 @@ public void LqlExpression_RightFunction_ExtractsSuffix() public void LqlExpression_TrimFunction_RemovesWhitespace() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "trim-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "clean_name", - Transform: TransformType.Lql, - Lql: "trim(Name)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] - ); + var config = MappingConfig(mappingId: "trim-test", LqlColumn("clean_name", "trim(Name)")); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":" Hello World "}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Name":" Hello World "}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -936,46 +728,15 @@ public void LqlExpression_TrimFunction_RemovesWhitespace() public void LqlExpression_LengthFunction_ReturnsStringLength() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "length-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "name_length", - Transform: TransformType.Lql, - Lql: "length(Name)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "length-test", + LqlColumn("name_length", "length(Name)") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":"Hello"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Name":"Hello"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -987,46 +748,20 @@ public void LqlExpression_LengthFunction_ReturnsStringLength() public void LqlExpression_NestedConcat_BuildsComplexString() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "nested-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "display", - Transform: TransformType.Lql, - Lql: "concat(Title, ': ', FirstName, ' ', LastName, ' (', Department, ')')" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "nested-test", + LqlColumn( + "display", + "concat(Title, ': ', FirstName, ' ', LastName, ' (', Department, ')')" + ) ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Title":"Dr","FirstName":"John","LastName":"Smith","Department":"Engineering"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" + var entry = SourceEntry( + """{"Id":"1","Title":"Dr","FirstName":"John","LastName":"Smith","Department":"Engineering"}""" ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -1041,46 +776,15 @@ public void LqlExpression_NestedConcat_BuildsComplexString() public void LqlExpression_CoalesceWithAllNull_ReturnsEmpty() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "coalesce-null-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "phone", - Transform: TransformType.Lql, - Lql: "coalesce(Mobile, Home, Work)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "coalesce-null-test", + LqlColumn("phone", "coalesce(Mobile, Home, Work)") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Mobile":"","Home":"","Work":""}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Mobile":"","Home":"","Work":""}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - coalesce returns null/empty when all values are empty var success = Assert.IsType(result); @@ -1100,47 +804,16 @@ public void LqlExpression_CoalesceWithAllNull_ReturnsEmpty() public void LqlExpression_DateFormatWithDifferentTimezones_PreservesUtc() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "tz-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "date_only", - Transform: TransformType.Lql, - Lql: "CreatedAt |> dateFormat('yyyy-MM-dd')" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "tz-test", + LqlColumn("date_only", "CreatedAt |> dateFormat('yyyy-MM-dd')") ); // Test with explicit timezone offset - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","CreatedAt":"2024-12-25T23:30:00+00:00"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","CreatedAt":"2024-12-25T23:30:00+00:00"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - should preserve UTC date var success = Assert.IsType(result); @@ -1158,18 +831,10 @@ public void MappingEngine_UnmappedTable_WithPassthrough_ReturnsIdentity() Mappings: [] // No mappings defined ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "UnknownTable", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Data":"test"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Data":"test"}""", tableName: "UnknownTable"); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - passthrough returns identity mapping var success = Assert.IsType(result); @@ -1188,18 +853,10 @@ public void MappingEngine_UnmappedTable_WithStrict_ReturnsSkipped() Mappings: [] // No mappings defined ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "UnknownTable", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Data":"test"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Data":"test"}""", tableName: "UnknownTable"); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - strict mode skips unmapped tables Assert.IsType(result); @@ -1229,18 +886,10 @@ public void MappingEngine_DisabledMapping_ReturnsSkipped() ] ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Data":"test"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Data":"test"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var skipped = Assert.IsType(result); @@ -1271,18 +920,10 @@ public void MappingEngine_WrongDirection_ReturnsSkipped() ] ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Data":"test"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Data":"test"}"""); // Act - try to use for Push direction - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - should be skipped because direction doesn't match Assert.IsType(result); @@ -1312,19 +953,11 @@ public void MappingEngine_BothDirection_WorksForPushAndPull() ] ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Data":"test"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Data":"test"}"""); // Act - should work for both directions - var pushResult = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); - var pullResult = MappingEngine.ApplyMapping(entry, config, MappingDirection.Pull, _logger); + var pushResult = ApplyMapping(entry, config); + var pullResult = ApplyMapping(entry, config, MappingDirection.Pull); // Assert Assert.IsType(pushResult); @@ -1337,46 +970,15 @@ public void MappingEngine_BothDirection_WorksForPushAndPull() public void LqlExpression_PipelineWithMultipleSteps_TransformsCorrectly() { // Arrange - use pipe operator to chain transforms - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "pipeline-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping( - Source: null, - Target: "clean_name", - Transform: TransformType.Lql, - Lql: "Name |> trim() |> upper()" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "pipeline-test", + LqlColumn("clean_name", "Name |> trim() |> upper()") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":" hello world "}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Name":" hello world "}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - should trim then uppercase var success = Assert.IsType(result); @@ -1388,42 +990,16 @@ public void LqlExpression_PipelineWithMultipleSteps_TransformsCorrectly() public void LqlExpression_NumericValue_PreservesType() { // Arrange - test numeric field handling - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "numeric-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping("Amount", "amount"), - new ColumnMapping("Count", "count"), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "numeric-test", + new ColumnMapping("Amount", "amount"), + new ColumnMapping("Count", "count") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Amount":123.45,"Count":42}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Amount":123.45,"Count":42}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - numeric values preserved var success = Assert.IsType(result); @@ -1436,42 +1012,16 @@ public void LqlExpression_NumericValue_PreservesType() public void LqlExpression_BooleanValue_PreservesType() { // Arrange - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "bool-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping("IsActive", "is_active"), - new ColumnMapping("IsVerified", "is_verified"), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "bool-test", + new ColumnMapping("IsActive", "is_active"), + new ColumnMapping("IsVerified", "is_verified") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","IsActive":true,"IsVerified":false}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","IsActive":true,"IsVerified":false}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -1484,38 +1034,15 @@ public void LqlExpression_BooleanValue_PreservesType() public void LqlExpression_SpecialCharactersInString_EscapedCorrectly() { // Arrange - strings with special JSON characters - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "escape-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: [new ColumnMapping("Description", "description")], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "escape-test", + new ColumnMapping("Description", "description") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Description":"Line1\nLine2\tTabbed \"quoted\""}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Description":"Line1\nLine2\tTabbed \"quoted\""}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - special chars preserved var success = Assert.IsType(result); @@ -1531,47 +1058,16 @@ public void LqlExpression_VeryLongString_HandledCorrectly() { // Arrange - test with very long string var longString = new string('x', 10000); - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "long-string-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping("Data", "data"), - new ColumnMapping( - Source: null, - Target: "data_length", - Transform: TransformType.Lql, - Lql: "length(Data)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "long-string-test", + new ColumnMapping("Data", "data"), + LqlColumn("data_length", "length(Data)") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: $$$"""{"Id":"1","Data":"{{{longString}}}"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry($$$"""{"Id":"1","Data":"{{{longString}}}"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert var success = Assert.IsType(result); @@ -1584,47 +1080,16 @@ public void LqlExpression_VeryLongString_HandledCorrectly() public void LqlExpression_UnicodeCharacters_PreservedCorrectly() { // Arrange - test Unicode handling - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "unicode-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping("Name", "name"), - new ColumnMapping( - Source: null, - Target: "name_upper", - Transform: TransformType.Lql, - Lql: "upper(Name)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "unicode-test", + new ColumnMapping("Name", "name"), + LqlColumn("name_upper", "upper(Name)") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":"日本語テスト 中文测试 émojis: 🎉🚀"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" - ); + var entry = SourceEntry("""{"Id":"1","Name":"日本語テスト 中文测试 émojis: 🎉🚀"}"""); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - Unicode preserved var success = Assert.IsType(result); @@ -1639,57 +1104,28 @@ public void LqlExpression_UnicodeCharacters_PreservedCorrectly() public void MappingEngine_MultipleColumnMappings_AllApplied() { // Arrange - many column mappings - var config = new SyncMappingConfig( - Version: "1.0", - UnmappedTableBehavior: UnmappedTableBehavior.Strict, - Mappings: - [ - new TableMapping( - Id: "multi-col-test", - SourceTable: "Source", - TargetTable: "target", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: null, - ColumnMappings: - [ - new ColumnMapping("Col1", "col_1"), - new ColumnMapping("Col2", "col_2"), - new ColumnMapping("Col3", "col_3"), - new ColumnMapping("Col4", "col_4"), - new ColumnMapping("Col5", "col_5"), - new ColumnMapping( - Source: null, - Target: "constant", - Transform: TransformType.Constant, - Value: "fixed" - ), - new ColumnMapping( - Source: null, - Target: "computed", - Transform: TransformType.Lql, - Lql: "concat(Col1, '-', Col2)" - ), - ], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ), - ] + var config = MappingConfig( + mappingId: "multi-col-test", + new ColumnMapping("Col1", "col_1"), + new ColumnMapping("Col2", "col_2"), + new ColumnMapping("Col3", "col_3"), + new ColumnMapping("Col4", "col_4"), + new ColumnMapping("Col5", "col_5"), + new ColumnMapping( + Source: null, + Target: "constant", + Transform: TransformType.Constant, + Value: "fixed" + ), + LqlColumn("computed", "concat(Col1, '-', Col2)") ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Col1":"A","Col2":"B","Col3":"C","Col4":"D","Col5":"E"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" + var entry = SourceEntry( + """{"Id":"1","Col1":"A","Col2":"B","Col3":"C","Col4":"D","Col5":"E"}""" ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - all mappings applied var success = Assert.IsType(result); @@ -1727,18 +1163,12 @@ public void MappingEngine_ExcludeMultipleColumns_AllExcluded() ] ); - var entry = new SyncLogEntry( - Version: 1, - TableName: "Source", - PkValue: """{"Id":"1"}""", - Operation: SyncOperation.Insert, - Payload: """{"Id":"1","Name":"User","Password":"hash","Salt":"xyz","Token":"abc","Secret":"123","PrivateKey":"key"}""", - Origin: "test", - Timestamp: "2024-01-01T00:00:00Z" + var entry = SourceEntry( + """{"Id":"1","Name":"User","Password":"hash","Salt":"xyz","Token":"abc","Secret":"123","PrivateKey":"key"}""" ); // Act - var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); + var result = ApplyMapping(entry, config); // Assert - excluded columns not in output var success = Assert.IsType(result); diff --git a/Sync/Nimblesite.Sync.Http.Tests/HttpSqlServerContainerFixture.cs b/Sync/Nimblesite.Sync.Http.Tests/HttpSqlServerContainerFixture.cs new file mode 100644 index 00000000..58125823 --- /dev/null +++ b/Sync/Nimblesite.Sync.Http.Tests/HttpSqlServerContainerFixture.cs @@ -0,0 +1,44 @@ +using Microsoft.Data.SqlClient; +using Testcontainers.MsSql; + +namespace Nimblesite.Sync.Http.Tests; + +public sealed record HttpSqlServerContainerFixture : IAsyncLifetime +{ + private readonly Lazy> _container = new(StartContainerAsync); + private long _databaseCounter; + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_container.IsValueCreated && _container.Value.IsCompletedSuccessfully) + { + var container = await _container.Value.ConfigureAwait(false); + await container.DisposeAsync().ConfigureAwait(false); + } + } + + public async Task CreateDatabaseConnectionStringAsync() + { + var container = await _container.Value.ConfigureAwait(false); + var name = $"sync_http_{Interlocked.Increment(ref _databaseCounter)}"; + using var admin = new SqlConnection(container.GetConnectionString()); + await admin.OpenAsync().ConfigureAwait(false); + using var command = admin.CreateCommand(); + command.CommandText = $"CREATE DATABASE [{name}]"; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + return new SqlConnectionStringBuilder(container.GetConnectionString()) + { + InitialCatalog = name, + Pooling = false, + }.ConnectionString; + } + + private static async Task StartContainerAsync() + { + var container = new MsSqlBuilder().Build(); + await container.StartAsync().ConfigureAwait(false); + return container; + } +} diff --git a/Sync/Nimblesite.Sync.Http.Tests/HttpSyncE2ETests.cs b/Sync/Nimblesite.Sync.Http.Tests/HttpSyncE2ETests.cs new file mode 100644 index 00000000..172272a2 --- /dev/null +++ b/Sync/Nimblesite.Sync.Http.Tests/HttpSyncE2ETests.cs @@ -0,0 +1,430 @@ +using System.Text.Json; + +namespace Nimblesite.Sync.Http.Tests; + +#pragma warning disable CA2213 // HttpClient managed by WebApplicationFactory - do not dispose + +/// +/// E2E tests proving REAL sync over HTTP works. +/// Uses WebApplicationFactory for real ASP.NET Core server + real SQLite databases. +/// This is the PROOF that Nimblesite.Sync.Http extension methods work in a real scenario. +/// +public sealed class HttpSyncE2ETests : IClassFixture, IDisposable +{ + private readonly HttpClient _client; + private readonly string _serverDbPath; + private readonly SqliteConnection _serverConn; + private readonly string _clientDbPath; + private readonly SqliteConnection _clientConn; + private readonly string _serverOriginId = Guid.NewGuid().ToString(); + private readonly string _clientOriginId = Guid.NewGuid().ToString(); + + public HttpSyncE2ETests(SyncApiWebApplicationFactory factory) + { + _client = factory.CreateClient(); + + // Create server database + _serverDbPath = Path.Combine(Path.GetTempPath(), $"sync_server_{Guid.NewGuid()}.db"); + _serverConn = new SqliteConnection($"Data Source={_serverDbPath}"); + _serverConn.Open(); + InitializeDatabase(_serverConn, _serverOriginId); + + // Create client database + _clientDbPath = Path.Combine(Path.GetTempPath(), $"sync_client_{Guid.NewGuid()}.db"); + _clientConn = new SqliteConnection($"Data Source={_clientDbPath}"); + _clientConn.Open(); + InitializeDatabase(_clientConn, _clientOriginId); + } + + private static void InitializeDatabase(SqliteConnection conn, string originId) + { + SyncSchema.CreateSchema(conn); + SyncSchema.SetOriginId(conn, originId); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE Person ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL, + Email TEXT + ); + """; + cmd.ExecuteNonQuery(); + + TriggerGenerator.CreateTriggers(conn, "Person", NullLogger.Instance); + } + + private static void AssertPerson( + SqliteConnection connection, + string id, + string expectedName, + string expectedEmail + ) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT Name, Email FROM Person WHERE Id = @id"; + command.Parameters.AddWithValue("@id", id); + using var reader = command.ExecuteReader(); + Assert.True(reader.Read(), $"Missing Person {id}"); + Assert.Equal(expectedName, reader.GetString(0)); + Assert.Equal(expectedEmail, reader.GetString(1)); + Assert.False(reader.Read(), $"Duplicate Person {id}"); + } + + private static void AssertLoggedInsert( + SyncLogEntry entry, + string id, + string name, + string email, + string originId + ) + { + Assert.True(entry.Version > 0); + Assert.Equal("Person", entry.TableName); + Assert.Equal(SyncOperation.Insert, entry.Operation); + Assert.Equal(originId, entry.Origin); + using var key = JsonDocument.Parse(Assert.IsType(entry.PkValue)); + Assert.Equal(id, key.RootElement.GetProperty("Id").GetString()); + using var payload = JsonDocument.Parse(Assert.IsType(entry.Payload)); + Assert.Equal(id, payload.RootElement.GetProperty("Id").GetString()); + Assert.Equal(name, payload.RootElement.GetProperty("Name").GetString()); + Assert.Equal(email, payload.RootElement.GetProperty("Email").GetString()); + } + + public void Dispose() + { + _client.Dispose(); + _serverConn.Close(); + _serverConn.Dispose(); + _clientConn.Close(); + _clientConn.Dispose(); + + try + { + if (File.Exists(_serverDbPath)) + File.Delete(_serverDbPath); + if (File.Exists(_clientDbPath)) + File.Delete(_clientDbPath); + } + catch + { + // Ignore cleanup errors + } + } + + /// + /// Tests that changes inserted on client can be pushed via HTTP and appear on server. + /// + [Fact] + public async Task PushChanges_InsertsOnClient_AppearsOnServer() + { + // Arrange - insert data on client + using (var cmd = _clientConn.CreateCommand()) + { + cmd.CommandText = + "INSERT INTO Person (Id, Name, Email) VALUES ('http1', 'HTTP Test', 'http@test.com')"; + cmd.ExecuteNonQuery(); + } + + // Get changes from client + var clientChanges = SyncLogRepository.FetchChanges(_clientConn, 0, 100); + var changesList = Assert.IsType(clientChanges).Value; + AssertLoggedInsert( + Assert.Single(changesList), + "http1", + "HTTP Test", + "http@test.com", + _clientOriginId + ); + + // Build request - OriginId is the origin to SKIP (server's own origin prevents echo) + // We're pushing FROM client, so server should NOT skip client's changes + var pushRequest = new + { + Changes = changesList + .Select(c => new + { + c.Version, + c.TableName, + c.PkValue, + Operation = c.Operation.ToString().ToLowerInvariant(), + c.Payload, + c.Origin, + c.Timestamp, + }) + .ToList(), + OriginId = _serverOriginId, + }; + + var requestJson = JsonSerializer.Serialize(pushRequest); + var content = new StringContent(requestJson, System.Text.Encoding.UTF8, "application/json"); + + var connStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); + + // Act - push changes via HTTP + var response = await _client.PostAsync( + $"/sync/changes?dbType=sqlite&connectionString={connStr}", + content + ); + + // Assert - HTTP success + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"Push failed: {response.StatusCode} - {body}"); + + AssertPerson(_serverConn, "http1", "HTTP Test", "http@test.com"); + + using var repeatContent = new StringContent( + requestJson, + System.Text.Encoding.UTF8, + "application/json" + ); + using var repeat = await _client.PostAsync( + $"/sync/changes?dbType=sqlite&connectionString={connStr}", + repeatContent + ); + Assert.True(repeat.IsSuccessStatusCode, await repeat.Content.ReadAsStringAsync()); + AssertPerson(_serverConn, "http1", "HTTP Test", "http@test.com"); + using var count = _serverConn.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM Person"; + Assert.Equal( + 1L, + Convert.ToInt64( + count.ExecuteScalar(), + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } + + /// + /// Tests that changes on server can be pulled via HTTP and applied to client. + /// + [Fact] + public async Task PullChanges_ChangesOnServer_AppearsOnClient() + { + // Arrange - insert data on server + using (var cmd = _serverConn.CreateCommand()) + { + cmd.CommandText = + "INSERT INTO Person (Id, Name, Email) VALUES ('pull1', 'Pull Test', 'pull@test.com')"; + cmd.ExecuteNonQuery(); + } + + var connStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); + + // Act - pull changes via HTTP + var response = await _client.GetAsync( + $"/sync/changes?fromVersion=0&batchSize=100&dbType=sqlite&connectionString={connStr}" + ); + + // Assert - HTTP success + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"Pull failed: {response.StatusCode} - {body}"); + + // Parse response (ASP.NET Core uses camelCase by default) + using var doc = JsonDocument.Parse(body); + var changesArray = doc.RootElement.GetProperty("changes"); + var pulled = Assert.Single(changesArray.EnumerateArray()); + Assert.True(pulled.GetProperty("version").GetInt64() > 0); + Assert.Equal("Person", pulled.GetProperty("tableName").GetString()); + Assert.Equal((int)SyncOperation.Insert, pulled.GetProperty("operation").GetInt32()); + Assert.Equal(_serverOriginId, pulled.GetProperty("origin").GetString()); + using var pulledPayload = JsonDocument.Parse( + pulled.GetProperty("payload").GetString() ?? "" + ); + Assert.Equal("pull1", pulledPayload.RootElement.GetProperty("Id").GetString()); + Assert.Equal("Pull Test", pulledPayload.RootElement.GetProperty("Name").GetString()); + + // Apply changes to client (operation is returned as integer enum value) + foreach (var change in changesArray.EnumerateArray()) + { + var entry = new SyncLogEntry( + Version: change.GetProperty("version").GetInt64(), + TableName: change.GetProperty("tableName").GetString() ?? "", + PkValue: change.GetProperty("pkValue").GetString() ?? "", + Operation: (SyncOperation)change.GetProperty("operation").GetInt32(), + Payload: change.GetProperty("payload").GetString() ?? "", + Origin: change.GetProperty("origin").GetString() ?? "", + Timestamp: change.GetProperty("timestamp").GetString() ?? "" + ); + + SyncSessionManager.EnableSuppression(_clientConn); + var result = ChangeApplierSQLite.ApplyChange(_clientConn, entry); + SyncSessionManager.DisableSuppression(_clientConn); + Assert.True(result is BoolSyncOk, $"Apply failed: {result}"); + } + + AssertPerson(_clientConn, "pull1", "Pull Test", "pull@test.com"); + Assert.Empty( + Assert.IsType(SyncLogRepository.FetchChanges(_clientConn, 0, 100)).Value + ); + + var lastVersion = pulled.GetProperty("version").GetInt64(); + using var nextPage = await _client.GetAsync( + $"/sync/changes?fromVersion={lastVersion}&batchSize=100&dbType=sqlite&connectionString={connStr}" + ); + Assert.Equal(System.Net.HttpStatusCode.OK, nextPage.StatusCode); + using var nextPageJson = JsonDocument.Parse(await nextPage.Content.ReadAsStringAsync()); + Assert.Empty(nextPageJson.RootElement.GetProperty("changes").EnumerateArray()); + } + + /// + /// Tests bidirectional sync - changes go both ways via HTTP. + /// + [Fact] + public async Task BidirectionalSync_ChangesBothWays_BothDatabasesHaveBothRecords() + { + // Arrange - insert different data on each side + using (var cmd = _serverConn.CreateCommand()) + { + cmd.CommandText = + "INSERT INTO Person (Id, Name, Email) VALUES ('bidir_server', 'Server Record', 'server@test.com')"; + cmd.ExecuteNonQuery(); + } + + using (var cmd = _clientConn.CreateCommand()) + { + cmd.CommandText = + "INSERT INTO Person (Id, Name, Email) VALUES ('bidir_client', 'Client Record', 'client@test.com')"; + cmd.ExecuteNonQuery(); + } + + var serverConnStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); + var clientConnStr = Uri.EscapeDataString($"Data Source={_clientDbPath}"); + + // Pull from server -> apply to client + var pullResponse = await _client.GetAsync( + $"/sync/changes?fromVersion=0&batchSize=100&dbType=sqlite&connectionString={serverConnStr}" + ); + Assert.True(pullResponse.IsSuccessStatusCode); + + var pullBody = await pullResponse.Content.ReadAsStringAsync(); + using var pullDoc = JsonDocument.Parse(pullBody); + Assert.Single(pullDoc.RootElement.GetProperty("changes").EnumerateArray()); + + foreach (var change in pullDoc.RootElement.GetProperty("changes").EnumerateArray()) + { + var entry = new SyncLogEntry( + Version: change.GetProperty("version").GetInt64(), + TableName: change.GetProperty("tableName").GetString() ?? "", + PkValue: change.GetProperty("pkValue").GetString() ?? "", + Operation: (SyncOperation)change.GetProperty("operation").GetInt32(), + Payload: change.GetProperty("payload").GetString() ?? "", + Origin: change.GetProperty("origin").GetString() ?? "", + Timestamp: change.GetProperty("timestamp").GetString() ?? "" + ); + + if (entry.Origin != _clientOriginId) + { + SyncSessionManager.EnableSuppression(_clientConn); + ChangeApplierSQLite.ApplyChange(_clientConn, entry); + SyncSessionManager.DisableSuppression(_clientConn); + } + } + + // Push from client -> server (OriginId = server's origin to not skip client changes) + var clientChanges = SyncLogRepository.FetchChanges(_clientConn, 0, 100); + var clientChangesList = Assert.IsType(clientChanges).Value; + AssertLoggedInsert( + Assert.Single(clientChangesList), + "bidir_client", + "Client Record", + "client@test.com", + _clientOriginId + ); + + var pushRequest = new + { + Changes = clientChangesList + .Select(c => new + { + c.Version, + c.TableName, + c.PkValue, + Operation = c.Operation.ToString().ToLowerInvariant(), + c.Payload, + c.Origin, + c.Timestamp, + }) + .ToList(), + OriginId = _serverOriginId, + }; + + var content = new StringContent( + JsonSerializer.Serialize(pushRequest), + System.Text.Encoding.UTF8, + "application/json" + ); + + var pushResponse = await _client.PostAsync( + $"/sync/changes?dbType=sqlite&connectionString={serverConnStr}", + content + ); + Assert.True(pushResponse.IsSuccessStatusCode); + + // Assert - both databases have both records + using var serverCmd = _serverConn.CreateCommand(); + serverCmd.CommandText = "SELECT COUNT(*) FROM Person"; + Assert.Equal( + 2L, + Convert.ToInt64( + serverCmd.ExecuteScalar(), + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + using var clientCmd = _clientConn.CreateCommand(); + clientCmd.CommandText = "SELECT COUNT(*) FROM Person"; + Assert.Equal( + 2L, + Convert.ToInt64( + clientCmd.ExecuteScalar(), + System.Globalization.CultureInfo.InvariantCulture + ) + ); + AssertPerson(_serverConn, "bidir_server", "Server Record", "server@test.com"); + AssertPerson(_serverConn, "bidir_client", "Client Record", "client@test.com"); + AssertPerson(_clientConn, "bidir_server", "Server Record", "server@test.com"); + AssertPerson(_clientConn, "bidir_client", "Client Record", "client@test.com"); + } + + /// + /// Tests sync state endpoint returns correct max version. + /// + [Fact] + public async Task SyncState_ReturnsCorrectMaxVersion() + { + // Arrange - insert multiple records + for (var i = 0; i < 5; i++) + { + using var cmd = _serverConn.CreateCommand(); + cmd.CommandText = + $"INSERT INTO Person (Id, Name, Email) VALUES ('state{i}', 'Person {i}', 'p{i}@test.com')"; + cmd.ExecuteNonQuery(); + } + + var connStr = Uri.EscapeDataString($"Data Source={_serverDbPath}"); + + // Act + var response = await _client.GetAsync( + $"/sync/state?dbType=sqlite&connectionString={connStr}" + ); + + // Assert + Assert.True(response.IsSuccessStatusCode); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + var maxVersion = doc.RootElement.GetProperty("maxVersion").GetInt64(); + Assert.Equal(5L, maxVersion); + + using var pull = await _client.GetAsync( + $"/sync/changes?fromVersion=0&batchSize=2&dbType=sqlite&connectionString={connStr}" + ); + Assert.Equal(System.Net.HttpStatusCode.OK, pull.StatusCode); + using var pullJson = JsonDocument.Parse(await pull.Content.ReadAsStringAsync()); + var page = pullJson.RootElement.GetProperty("changes").EnumerateArray().ToArray(); + Assert.Equal(2, page.Length); + Assert.Equal(1L, page[0].GetProperty("version").GetInt64()); + Assert.Equal(2L, page[1].GetProperty("version").GetInt64()); + } +} diff --git a/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj b/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj index 610e8435..39199122 100644 --- a/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj +++ b/Sync/Nimblesite.Sync.Http.Tests/Nimblesite.Sync.Http.Tests.csproj @@ -21,8 +21,10 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -34,6 +36,7 @@ + diff --git a/Sync/Nimblesite.Sync.Http/SyncHelpers.cs b/Sync/Nimblesite.Sync.Http/SyncHelpers.cs index 4ac29bba..64aa6ad4 100644 --- a/Sync/Nimblesite.Sync.Http/SyncHelpers.cs +++ b/Sync/Nimblesite.Sync.Http/SyncHelpers.cs @@ -120,27 +120,13 @@ ILogger logger try { - var applied = 0; - foreach (var change in changes) - { - if (change.Origin == originId) - continue; - - var entry = new SyncLogEntry( - change.Version, - change.TableName, - change.PkValue, - Enum.Parse(change.Operation, true), - change.Payload, - change.Origin, - change.Timestamp - ); - - var result = SQLite.ChangeApplierSQLite.ApplyChange(conn, entry); - if (result is Outcome.Result.Ok) - applied++; - } - return applied; + return ApplyChangesCore( + changes: changes, + originId: originId, + applyChange: entry => + SQLite.ChangeApplierSQLite.ApplyChange(conn, entry) + is Outcome.Result.Ok + ); } finally { @@ -162,27 +148,13 @@ ILogger logger try { - var applied = 0; - foreach (var change in changes) - { - if (change.Origin == originId) - continue; - - var entry = new SyncLogEntry( - change.Version, - change.TableName, - change.PkValue, - Enum.Parse(change.Operation, true), - change.Payload, - change.Origin, - change.Timestamp - ); - - var result = Postgres.PostgresChangeApplier.ApplyChange(conn, entry, logger); - if (result is Outcome.Result.Ok) - applied++; - } - return applied; + return ApplyChangesCore( + changes: changes, + originId: originId, + applyChange: entry => + Postgres.PostgresChangeApplier.ApplyChange(conn, entry, logger) + is Outcome.Result.Ok + ); } finally { @@ -190,6 +162,36 @@ ILogger logger } } + private static int ApplyChangesCore( + IReadOnlyList changes, + string originId, + Func applyChange + ) + { + var applied = 0; + foreach (var change in changes) + { + if (change.Origin == originId) + continue; + + if (applyChange(ToSyncLogEntry(change: change))) + applied++; + } + + return applied; + } + + private static SyncLogEntry ToSyncLogEntry(SyncLogEntryDto change) => + new( + Version: change.Version, + TableName: change.TableName, + PkValue: change.PkValue, + Operation: Enum.Parse(value: change.Operation, ignoreCase: true), + Payload: change.Payload, + Origin: change.Origin, + Timestamp: change.Timestamp + ); + private static bool UpsertClientSqlite( string connectionString, SyncClient client, diff --git a/Sync/Nimblesite.Sync.Integration.Tests/HttpMappingSyncTests.cs b/Sync/Nimblesite.Sync.Integration.Tests/HttpMappingSyncTests.cs index a8f1e887..fbb6372c 100644 --- a/Sync/Nimblesite.Sync.Integration.Tests/HttpMappingSyncTests.cs +++ b/Sync/Nimblesite.Sync.Integration.Tests/HttpMappingSyncTests.cs @@ -111,6 +111,26 @@ registered_date TEXT return conn; } + /// + /// Fetches all source changes, maps the first one in the push direction, + /// asserts the mapping succeeded, and returns the first mapped entry. + /// + private MappedEntry MapFirstChange(SqliteConnection source, SyncMappingConfig mappingConfig) + { + var changes = SyncLogRepository.FetchChanges(source, 0, 100); + var entry = ((SyncLogListOk)changes).Value[0]; + + var mappingResult = MappingEngine.ApplyMapping( + entry, + mappingConfig, + MappingDirection.Push, + _logger + ); + + var success = Assert.IsType(mappingResult); + return success.Entries[0]; + } + /// /// PROVES: MappingEngine transforms User -> Customer with column renaming. /// Source: User(Id, FullName, EmailAddress) @@ -134,20 +154,7 @@ public void Mapping_TransformsColumnsFromUserToCustomer() new(null, "source", TransformType.Constant, "mobile-app"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: ["PasswordHash", "SecurityStamp"], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, ["PasswordHash", "SecurityStamp"]); // Act - Insert in source with SOURCE schema columns using (var cmd = source.CreateCommand()) @@ -226,20 +233,7 @@ public void FullSync_WithMapping_TransformsAndApplies() new(null, "source", TransformType.Constant, "sync-test"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: ["PasswordHash", "SecurityStamp"], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, ["PasswordHash", "SecurityStamp"]); // Insert in source using (var cmd = source.CreateCommand()) @@ -419,20 +413,7 @@ public void UpdateOperation_MapsCorrectly() new("EmailAddress", "email"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); // Insert using (var cmd = source.CreateCommand()) @@ -491,20 +472,7 @@ public void DeleteOperation_MapsPrimaryKeyCorrectly() var sourceOrigin = Guid.NewGuid().ToString(); using var source = CreateSourceDb(sourceOrigin); - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: [], - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig([], []); // Insert then delete using (var cmd = source.CreateCommand()) @@ -618,20 +586,7 @@ public void NullValues_InPayload_MapsCorrectly() new("EmailAddress", "email"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); // Insert with NULL email using (var cmd = source.CreateCommand()) @@ -676,20 +631,7 @@ public void UnicodeCharacters_PreservedThroughMapping() new("EmailAddress", "email"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); // Unicode characters: Japanese, Chinese, Korean, Arabic, Emoji var unicodeNames = new[] @@ -782,20 +724,7 @@ public void SpecialCharacters_InData_HandledCorrectly() new("EmailAddress", "email"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); // Special chars var specialCases = new[] @@ -867,20 +796,7 @@ public void EmptyString_NotConfusedWithNull() new("EmailAddress", "email"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); // Insert with empty string (not NULL) using (var cmd = source.CreateCommand()) @@ -890,18 +806,7 @@ public void EmptyString_NotConfusedWithNull() cmd.ExecuteNonQuery(); } - var changes = SyncLogRepository.FetchChanges(source, 0, 100); - var entry = ((SyncLogListOk)changes).Value[0]; - - var mappingResult = MappingEngine.ApplyMapping( - entry, - mappingConfig, - MappingDirection.Push, - _logger - ); - - var success = Assert.IsType(mappingResult); - var mappedEntry = success.Entries[0]; + var mappedEntry = MapFirstChange(source: source, mappingConfig: mappingConfig); Assert.Contains("email", mappedEntry.MappedPayload); // Empty string should be "" @@ -923,20 +828,7 @@ public void VeryLongStrings_HandledCorrectly() new("EmailAddress", "email"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); // Very long string (10000 chars) var longName = new string('A', 10000); @@ -964,18 +856,7 @@ public void VeryLongStrings_HandledCorrectly() cmd.ExecuteNonQuery(); } - var changes = SyncLogRepository.FetchChanges(source, 0, 100); - var entry = ((SyncLogListOk)changes).Value[0]; - - var mappingResult = MappingEngine.ApplyMapping( - entry, - mappingConfig, - MappingDirection.Push, - _logger - ); - - var success = Assert.IsType(mappingResult); - var mappedEntry = success.Entries[0]; + var mappedEntry = MapFirstChange(source: source, mappingConfig: mappingConfig); Assert.NotNull(mappedEntry.MappedPayload); Assert.Contains(longName, mappedEntry.MappedPayload); @@ -999,20 +880,7 @@ public void ConstantTransform_SpecialValues_Work() new(null, "notes", TransformType.Constant, ""), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); using (var cmd = source.CreateCommand()) { @@ -1021,18 +889,7 @@ public void ConstantTransform_SpecialValues_Work() cmd.ExecuteNonQuery(); } - var changes = SyncLogRepository.FetchChanges(source, 0, 100); - var entry = ((SyncLogListOk)changes).Value[0]; - - var mappingResult = MappingEngine.ApplyMapping( - entry, - mappingConfig, - MappingDirection.Push, - _logger - ); - - var success = Assert.IsType(mappingResult); - var mappedEntry = success.Entries[0]; + var mappedEntry = MapFirstChange(source: source, mappingConfig: mappingConfig); Assert.Contains("status", mappedEntry.MappedPayload); Assert.Contains("active", mappedEntry.MappedPayload); @@ -1051,28 +908,11 @@ public void AllColumnsExcluded_MinimalPayload() var sourceOrigin = Guid.NewGuid().ToString(); using var source = CreateSourceDb(sourceOrigin); - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: [], - ExcludedColumns: - [ - "FullName", - "EmailAddress", - "PasswordHash", - "SecurityStamp", - "CreatedAt", - ], - Filter: null, - SyncTracking: new SyncTrackingConfig() + var mappingConfig = UserToCustomerConfig( + [], + ["FullName", "EmailAddress", "PasswordHash", "SecurityStamp", "CreatedAt"] ); - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); - using (var cmd = source.CreateCommand()) { cmd.CommandText = @@ -1080,18 +920,7 @@ public void AllColumnsExcluded_MinimalPayload() cmd.ExecuteNonQuery(); } - var changes = SyncLogRepository.FetchChanges(source, 0, 100); - var entry = ((SyncLogListOk)changes).Value[0]; - - var mappingResult = MappingEngine.ApplyMapping( - entry, - mappingConfig, - MappingDirection.Push, - _logger - ); - - var success = Assert.IsType(mappingResult); - var mappedEntry = success.Entries[0]; + var mappedEntry = MapFirstChange(source: source, mappingConfig: mappingConfig); // PK should still be there Assert.Contains("customer_id", mappedEntry.TargetPkValue); @@ -1119,20 +948,7 @@ public void JsonInPayload_PreservedCorrectly() new("EmailAddress", "email"), }; - var mapping = new TableMapping( - Id: "user-to-customer", - SourceTable: "User", - TargetTable: "customer", - Direction: MappingDirection.Push, - Enabled: true, - PkMapping: new PkMapping("Id", "customer_id"), - ColumnMappings: columnMappings, - ExcludedColumns: [], - Filter: null, - SyncTracking: new SyncTrackingConfig() - ); - - var mappingConfig = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var mappingConfig = UserToCustomerConfig(columnMappings, []); // Name contains JSON-like structure var jsonLikeName = "{\"first\":\"John\",\"last\":\"Doe\"}"; @@ -1160,18 +976,7 @@ public void JsonInPayload_PreservedCorrectly() cmd.ExecuteNonQuery(); } - var changes = SyncLogRepository.FetchChanges(source, 0, 100); - var entry = ((SyncLogListOk)changes).Value[0]; - - var mappingResult = MappingEngine.ApplyMapping( - entry, - mappingConfig, - MappingDirection.Push, - _logger - ); - - var success = Assert.IsType(mappingResult); - var mappedEntry = success.Entries[0]; + var mappedEntry = MapFirstChange(source: source, mappingConfig: mappingConfig); Assert.Contains("name", mappedEntry.MappedPayload); // The nested JSON should be preserved as a string value @@ -1180,6 +985,29 @@ public void JsonInPayload_PreservedCorrectly() #endregion + private static SyncMappingConfig UserToCustomerConfig( + IReadOnlyList columnMappings, + IReadOnlyList excludedColumns + ) => + new( + "1.0", + UnmappedTableBehavior.Strict, + [ + new TableMapping( + Id: "user-to-customer", + SourceTable: "User", + TargetTable: "customer", + Direction: MappingDirection.Push, + Enabled: true, + PkMapping: new PkMapping("Id", "customer_id"), + ColumnMappings: columnMappings, + ExcludedColumns: excludedColumns, + Filter: null, + SyncTracking: new SyncTrackingConfig() + ), + ] + ); + /// /// Helper to apply a mapped entry to PostgreSQL target table. /// Note: Table name is from test fixtures, not user input - safe for test code. diff --git a/Sync/Nimblesite.Sync.Postgres.Tests/PostgresRepositoryTests.cs b/Sync/Nimblesite.Sync.Postgres.Tests/PostgresRepositoryTests.cs index 0fff2922..18b7eec6 100644 --- a/Sync/Nimblesite.Sync.Postgres.Tests/PostgresRepositoryTests.cs +++ b/Sync/Nimblesite.Sync.Postgres.Tests/PostgresRepositoryTests.cs @@ -899,4 +899,33 @@ name TEXT NOT NULL } #endregion + + [Fact] + public async Task Repositories_WithoutSyncSchema_ReturnDatabaseErrors() + { + using var bare = await fixture + .CreateDatabaseAsync("sync_repo_no_schema") + .ConfigureAwait(true); + var client = new SyncClient("origin-1", 1, "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z"); + var entry = new SyncLogEntry( + 0, + "TestTable", + "{\"Id\":\"t1\"}", + SyncOperation.Insert, + "{\"Id\":\"t1\"}", + "origin-1", + "2025-01-01T00:00:00Z" + ); + + Assert.True(PostgresSyncClientRepository.GetAll(bare).IsError); + Assert.True(PostgresSyncClientRepository.GetByOrigin(bare, "origin-1").IsError); + Assert.True(PostgresSyncClientRepository.Upsert(bare, client).IsError); + Assert.True(PostgresSyncClientRepository.Delete(bare, "origin-1").IsError); + Assert.True(PostgresSyncClientRepository.GetMinVersion(bare).IsError); + Assert.True(PostgresSyncLogRepository.FetchChanges(bare, 0, 10).IsError); + Assert.True(PostgresSyncLogRepository.Insert(bare, entry).IsError); + Assert.True(PostgresSyncLogRepository.GetLastServerVersion(bare).IsError); + Assert.True(PostgresSyncLogRepository.GetMaxVersion(bare).IsError); + Assert.True(PostgresSyncLogRepository.UpdateLastServerVersion(bare, 5).IsError); + } } diff --git a/Sync/Nimblesite.Sync.Postgres/PostgresChangeApplier.cs b/Sync/Nimblesite.Sync.Postgres/PostgresChangeApplier.cs index 6edd1cd8..13675920 100644 --- a/Sync/Nimblesite.Sync.Postgres/PostgresChangeApplier.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresChangeApplier.cs @@ -111,7 +111,7 @@ ILogger logger var i = 0; foreach (var kvp in payload) { - cmd.Parameters.AddWithValue($"@p{i}", GetJsonValue(kvp.Value)); + AddValue(cmd, $"@p{i}", GetJsonValue(kvp.Value)); i++; } @@ -137,16 +137,14 @@ ILogger logger return new BoolSyncError(new SyncErrorDatabase("Invalid payload format")); } - var pkJson = JsonSerializer.Deserialize>(entry.PkValue); - if (pkJson is null || pkJson.Count == 0) + var pkParts = TryParsePk(entry, out var pkError); + if (pkParts is not (var pkColumn, var pkColumnLower, var pkValue)) { - return new BoolSyncError(new SyncErrorDatabase("Invalid pk_value format")); + return pkError is { } err + ? err + : new BoolSyncError(new SyncErrorDatabase("Invalid pk_value format")); } - var pkColumn = pkJson.Keys.First(); - var pkColumnLower = pkColumn.ToLowerInvariant(); - var pkValue = GetJsonValue(pkJson[pkColumn]); - var setClauses = new List(); var paramIndex = 0; @@ -157,7 +155,7 @@ ILogger logger if (!kvp.Key.Equals(pkColumn, StringComparison.OrdinalIgnoreCase)) { setClauses.Add($"{kvp.Key.ToLowerInvariant()} = @p{paramIndex}"); - cmd.Parameters.AddWithValue($"@p{paramIndex}", GetJsonValue(kvp.Value)); + AddValue(cmd, $"@p{paramIndex}", GetJsonValue(kvp.Value)); paramIndex++; } } @@ -171,7 +169,7 @@ ILogger logger var tableName = entry.TableName.ToLowerInvariant(); cmd.CommandText = $"UPDATE {tableName} SET {string.Join(", ", setClauses)} WHERE {pkColumnLower} = @pkValue"; - cmd.Parameters.AddWithValue("@pkValue", pkValue); + AddValue(cmd, "@pkValue", pkValue); var affected = cmd.ExecuteNonQuery(); if (affected == 0) @@ -190,26 +188,53 @@ private static BoolSyncResult ApplyDelete( ILogger logger ) { - var pkJson = JsonSerializer.Deserialize>(entry.PkValue); - if (pkJson is null || pkJson.Count == 0) + var pkParts = TryParsePk(entry, out var pkError); + if (pkParts is not (_, var pkColumnLower, var pkValue)) { - return new BoolSyncError(new SyncErrorDatabase("Invalid pk_value format")); + return pkError is { } err + ? err + : new BoolSyncError(new SyncErrorDatabase("Invalid pk_value format")); } - var pkColumn = pkJson.Keys.First(); - var pkColumnLower = pkColumn.ToLowerInvariant(); - var pkValue = GetJsonValue(pkJson[pkColumn]); var tableName = entry.TableName.ToLowerInvariant(); using var cmd = connection.CreateCommand(); cmd.CommandText = $"DELETE FROM {tableName} WHERE {pkColumnLower} = @pkValue"; - cmd.Parameters.AddWithValue("@pkValue", pkValue); + AddValue(cmd, "@pkValue", pkValue); cmd.ExecuteNonQuery(); logger.LogDebug("POSTGRES APPLY: Delete successful for {Table}", entry.TableName); return new BoolSyncOk(true); } + // Shared PK-JSON parse+validate prologue for ApplyUpdate/ApplyDelete. + private static (string PkColumn, string PkColumnLower, object PkValue)? TryParsePk( + SyncLogEntry entry, + out BoolSyncResult? error + ) + { + var pkJson = JsonSerializer.Deserialize>(entry.PkValue); + if (pkJson is null || pkJson.Count == 0) + { + error = new BoolSyncError(new SyncErrorDatabase("Invalid pk_value format")); + return null; + } + + var pkColumn = pkJson.Keys.First(); + error = null; + return (pkColumn, pkColumn.ToLowerInvariant(), GetJsonValue(pkJson[pkColumn])); + } + + // Sync payloads carry uuid, timestamp and similar values as JSON strings. Sending + // strings untyped lets PostgreSQL convert them to each target column's type instead + // of rejecting text for a uuid column (42804). + private static void AddValue(NpgsqlCommand cmd, string name, object value) => + cmd.Parameters.Add( + value is string + ? new NpgsqlParameter(name, NpgsqlTypes.NpgsqlDbType.Unknown) { Value = value } + : new NpgsqlParameter(name, value) + ); + private static object GetJsonValue(JsonElement element) => element.ValueKind switch { diff --git a/Sync/Nimblesite.Sync.Postgres/PostgresSyncClientRepository.cs b/Sync/Nimblesite.Sync.Postgres/PostgresSyncClientRepository.cs index 8b1d7386..cc5f24fd 100644 --- a/Sync/Nimblesite.Sync.Postgres/PostgresSyncClientRepository.cs +++ b/Sync/Nimblesite.Sync.Postgres/PostgresSyncClientRepository.cs @@ -27,14 +27,7 @@ ORDER BY last_sync_version ASC while (reader.Read()) { - clients.Add( - new SyncClient( - OriginId: reader.GetString(0), - LastSyncVersion: reader.GetInt64(1), - LastSyncTimestamp: reader.GetString(2), - CreatedAt: reader.GetString(3) - ) - ); + clients.Add(MapClient(reader)); } return new SyncClientListOk(clients); @@ -72,12 +65,7 @@ FROM _sync_clients return new SyncClientOk(null); } - var client = new SyncClient( - OriginId: reader.GetString(0), - LastSyncVersion: reader.GetInt64(1), - LastSyncTimestamp: reader.GetString(2), - CreatedAt: reader.GetString(3) - ); + var client = MapClient(reader); return new SyncClientOk(client); } @@ -171,4 +159,12 @@ public static LongSyncResult GetMinVersion(NpgsqlConnection connection) ); } } + + private static SyncClient MapClient(NpgsqlDataReader reader) => + new( + OriginId: reader.GetString(0), + LastSyncVersion: reader.GetInt64(1), + LastSyncTimestamp: reader.GetString(2), + CreatedAt: reader.GetString(3) + ); } diff --git a/Sync/Nimblesite.Sync.SQLite.Tests/MappingRepositoryIntegrationTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/MappingRepositoryIntegrationTests.cs new file mode 100644 index 00000000..0faed495 --- /dev/null +++ b/Sync/Nimblesite.Sync.SQLite.Tests/MappingRepositoryIntegrationTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Data.Sqlite; +using Xunit; +using MappingStateListOk = Outcome.Result< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>.Ok< + System.Collections.Generic.IReadOnlyList, + Nimblesite.Sync.Core.SyncError +>; +using MappingStateOk = Outcome.Result< + Nimblesite.Sync.Core.MappingStateEntry?, + Nimblesite.Sync.Core.SyncError +>.Ok; +using RecordHashOk = Outcome.Result< + Nimblesite.Sync.Core.RecordHashEntry?, + Nimblesite.Sync.Core.SyncError +>.Ok; + +namespace Nimblesite.Sync.SQLite.Tests; + +/// +/// Mapping state and record hash persistence (spec Section 7.5.2) on a real SQLite file. +/// +public sealed class MappingRepositoryIntegrationTests : IDisposable +{ + private const string Timestamp = "2025-01-01T00:00:00.000Z"; + private readonly string _dbPath = Path.Combine( + Path.GetTempPath(), + $"mappingrepository_{Guid.NewGuid():N}.db" + ); + private readonly SqliteConnection _db; + + public MappingRepositoryIntegrationTests() + { + _db = new SqliteConnection($"Data Source={_dbPath}"); + _db.Open(); + Assert.IsType(SyncSchema.CreateSchema(_db)); + } + + [Fact] + public void MappingState_UpsertGetListAndDelete_RoundTripsExactValues() + { + Assert.Null( + Assert.IsType(MappingRepository.GetMappingState(_db, "b")).Value + ); + + Assert.IsType( + MappingRepository.UpsertMappingState(_db, new MappingStateEntry("b", 3, Timestamp, 7)) + ); + Assert.IsType( + MappingRepository.UpsertMappingState(_db, MappingStateEntry.Initial("a", Timestamp)) + ); + Assert.IsType( + MappingRepository.UpsertMappingState(_db, new MappingStateEntry("b", 9, Timestamp, 12)) + ); + + Assert.Equal( + new MappingStateEntry("b", 9, Timestamp, 12), + Assert.IsType(MappingRepository.GetMappingState(_db, "b")).Value + ); + Assert.Equal( + [ + MappingStateEntry.Initial("a", Timestamp), + new MappingStateEntry("b", 9, Timestamp, 12), + ], + Assert.IsType(MappingRepository.GetAllMappingStates(_db)).Value + ); + + Assert.IsType(MappingRepository.DeleteMappingState(_db, "a")); + Assert.Equal( + [new MappingStateEntry("b", 9, Timestamp, 12)], + Assert.IsType(MappingRepository.GetAllMappingStates(_db)).Value + ); + } + + [Fact] + public void RecordHashes_UpsertCountAndDelete_TrackOnlyTheirMapping() + { + Assert.Null( + Assert.IsType(MappingRepository.GetRecordHash(_db, "m1", "pk-1")).Value + ); + Assert.IsType( + MappingRepository.UpsertRecordHash( + _db, + new RecordHashEntry("m1", "pk-1", "h1", Timestamp) + ) + ); + Assert.IsType( + MappingRepository.UpsertRecordHash( + _db, + new RecordHashEntry("m1", "pk-1", "h2", Timestamp) + ) + ); + Assert.IsType( + MappingRepository.UpsertRecordHash( + _db, + new RecordHashEntry("m1", "pk-2", "h3", Timestamp) + ) + ); + Assert.IsType( + MappingRepository.UpsertRecordHash( + _db, + new RecordHashEntry("m2", "pk-1", "h4", Timestamp) + ) + ); + + Assert.Equal( + new RecordHashEntry("m1", "pk-1", "h2", Timestamp), + Assert.IsType(MappingRepository.GetRecordHash(_db, "m1", "pk-1")).Value + ); + Assert.Equal( + 2L, + Assert.IsType(MappingRepository.CountRecordHashes(_db, "m1")).Value + ); + + Assert.IsType(MappingRepository.DeleteRecordHash(_db, "m1", "pk-2")); + Assert.Equal( + 1L, + Assert.IsType(MappingRepository.CountRecordHashes(_db, "m1")).Value + ); + + Assert.Equal( + 1, + Assert.IsType(MappingRepository.DeleteRecordHashesByMapping(_db, "m1")).Value + ); + Assert.Equal( + 0L, + Assert.IsType(MappingRepository.CountRecordHashes(_db, "m1")).Value + ); + Assert.Equal( + 1L, + Assert.IsType(MappingRepository.CountRecordHashes(_db, "m2")).Value + ); + } + + [Fact] + public void Repository_WithoutSyncSchema_ReturnsDatabaseErrors() + { + var barePath = Path.Combine( + Path.GetTempPath(), + $"mappingrepository_bare_{Guid.NewGuid():N}.db" + ); + using var cleanup = new BareDatabase(barePath); + using var bare = new SqliteConnection($"Data Source={barePath}"); + bare.Open(); + Assert.True(MappingRepository.GetMappingState(bare, "a").IsError); + Assert.True(MappingRepository.GetAllMappingStates(bare).IsError); + Assert.True( + MappingRepository + .UpsertMappingState(bare, MappingStateEntry.Initial("a", Timestamp)) + .IsError + ); + Assert.True(MappingRepository.DeleteMappingState(bare, "a").IsError); + Assert.True(MappingRepository.GetRecordHash(bare, "a", "pk").IsError); + Assert.True( + MappingRepository + .UpsertRecordHash(bare, new RecordHashEntry("a", "pk", "h", Timestamp)) + .IsError + ); + Assert.True(MappingRepository.DeleteRecordHash(bare, "a", "pk").IsError); + Assert.True(MappingRepository.DeleteRecordHashesByMapping(bare, "a").IsError); + Assert.True(MappingRepository.CountRecordHashes(bare, "a").IsError); + } + + private sealed record BareDatabase(string Path) : IDisposable + { + public void Dispose() + { + SqliteConnection.ClearAllPools(); + File.Delete(Path); + } + } + + public void Dispose() + { + _db.Dispose(); + SqliteConnection.ClearAllPools(); + File.Delete(_dbPath); + } +} diff --git a/Sync/Nimblesite.Sync.SQLite.Tests/SpecComplianceTests.cs b/Sync/Nimblesite.Sync.SQLite.Tests/SpecComplianceTests.cs index 7c3226f5..c8e7d9f4 100644 --- a/Sync/Nimblesite.Sync.SQLite.Tests/SpecComplianceTests.cs +++ b/Sync/Nimblesite.Sync.SQLite.Tests/SpecComplianceTests.cs @@ -61,9 +61,7 @@ public void Spec_S5_OriginId_Is36CharUuid() public void Spec_S5_OriginId_IncludedInEveryChangeLogEntry() { // Spec S5.5: Origin ID MUST be included in every change log entry - InsertPerson("p1", "Alice", "alice@example.com"); - UpdatePerson("p1", "Alice Updated", "alice2@example.com"); - DeletePerson("p1"); + SeedInsertUpdateDeleteP1(); var changes = FetchChanges(0); Assert.Equal(3, changes.Count); @@ -103,9 +101,7 @@ public void Spec_S6_Timestamps_Iso8601UtcWithMilliseconds() public void Spec_S7_UnifiedChangeLog_AllOperationsInSingleTable() { // Spec S7.1: All changes in single unified table with JSON payloads - InsertPerson("p1", "Alice", "alice@example.com"); - UpdatePerson("p1", "Alice Updated", "alice2@example.com"); - DeletePerson("p1"); + SeedInsertUpdateDeleteP1(); var changes = FetchChanges(0); Assert.Equal(3, changes.Count); @@ -941,6 +937,13 @@ private void DeletePerson(string id) cmd.ExecuteNonQuery(); } + private void SeedInsertUpdateDeleteP1() + { + InsertPerson("p1", "Alice", "alice@example.com"); + UpdatePerson("p1", "Alice Updated", "alice2@example.com"); + DeletePerson("p1"); + } + private List FetchChanges(long fromVersion) { var result = SyncLogRepository.FetchChanges(_db, fromVersion, 1000); diff --git a/Sync/Nimblesite.Sync.SQLite/ChangeApplierSQLite.cs b/Sync/Nimblesite.Sync.SQLite/ChangeApplierSQLite.cs index 315df807..b1ffe439 100644 --- a/Sync/Nimblesite.Sync.SQLite/ChangeApplierSQLite.cs +++ b/Sync/Nimblesite.Sync.SQLite/ChangeApplierSQLite.cs @@ -44,38 +44,15 @@ public static BoolSyncResult ApplyChange(SqliteConnection connection, SyncLogEnt } } - [SuppressMessage( - "Security", - "CA2100:Review SQL queries for security vulnerabilities", - Justification = "Table names come from internal sync log, not user input" - )] private static BoolSyncResult ApplyInsert(SqliteConnection connection, SyncLogEntry entry) { - if (string.IsNullOrEmpty(entry.Payload)) - { - return new BoolSyncError(new SyncErrorDatabase("Insert requires payload")); - } - - var data = JsonSerializer.Deserialize>(entry.Payload); - if (data == null || data.Count == 0) + var (data, error) = ParsePayload(payload: entry.Payload, operation: "Insert"); + if (data is null) { - return new BoolSyncError(new SyncErrorDatabase("Invalid payload JSON")); + return error ?? new BoolSyncError(new SyncErrorDatabase("Invalid payload JSON")); } - var columns = string.Join(", ", data.Keys); - var parameters = string.Join(", ", data.Keys.Select(k => $"@{k}")); - - using var cmd = connection.CreateCommand(); - cmd.CommandText = - $"INSERT OR REPLACE INTO {entry.TableName} ({columns}) VALUES ({parameters})"; - - foreach (var kvp in data) - { - cmd.Parameters.AddWithValue($"@{kvp.Key}", JsonElementToValue(kvp.Value)); - } - - cmd.ExecuteNonQuery(); - return new BoolSyncOk(true); + return UpsertRow(connection: connection, entry: entry, data: data); } [SuppressMessage( @@ -85,27 +62,18 @@ private static BoolSyncResult ApplyInsert(SqliteConnection connection, SyncLogEn )] private static BoolSyncResult ApplyUpdate(SqliteConnection connection, SyncLogEntry entry) { - if (string.IsNullOrEmpty(entry.Payload)) + var (data, error) = ParsePayload(payload: entry.Payload, operation: "Update"); + if (data is null) { - return new BoolSyncError(new SyncErrorDatabase("Update requires payload")); - } - - var data = JsonSerializer.Deserialize>(entry.Payload); - if (data == null || data.Count == 0) - { - return new BoolSyncError(new SyncErrorDatabase("Invalid payload JSON")); + return error ?? new BoolSyncError(new SyncErrorDatabase("Invalid payload JSON")); } // Extract PK info - var pkData = JsonSerializer.Deserialize>(entry.PkValue); - if (pkData == null || pkData.Count == 0) + if (ParsePkValue(entry.PkValue) is not (string pkColumn, object pkValue)) { return new BoolSyncError(new SyncErrorDatabase("Invalid pk_value JSON")); } - var pkColumn = pkData.Keys.First(); - var pkValue = JsonElementToValue(pkData[pkColumn]); - // Check for version-based conflict resolution // If record has a Version column, only apply if incoming version is newer if (data.TryGetValue("Version", out var incomingVersionElement)) @@ -140,20 +108,7 @@ private static BoolSyncResult ApplyUpdate(SqliteConnection connection, SyncLogEn } // Apply the update using UPSERT - var columns = string.Join(", ", data.Keys); - var parameters = string.Join(", ", data.Keys.Select(k => $"@{k}")); - - using var cmd = connection.CreateCommand(); - cmd.CommandText = - $"INSERT OR REPLACE INTO {entry.TableName} ({columns}) VALUES ({parameters})"; - - foreach (var kvp in data) - { - cmd.Parameters.AddWithValue($"@{kvp.Key}", JsonElementToValue(kvp.Value)); - } - - cmd.ExecuteNonQuery(); - return new BoolSyncOk(true); + return UpsertRow(connection: connection, entry: entry, data: data); } [SuppressMessage( @@ -163,15 +118,11 @@ private static BoolSyncResult ApplyUpdate(SqliteConnection connection, SyncLogEn )] private static BoolSyncResult ApplyDelete(SqliteConnection connection, SyncLogEntry entry) { - var pkData = JsonSerializer.Deserialize>(entry.PkValue); - if (pkData == null || pkData.Count == 0) + if (ParsePkValue(entry.PkValue) is not (string pkColumn, object pkValue)) { return new BoolSyncError(new SyncErrorDatabase("Invalid pk_value JSON")); } - var pkColumn = pkData.Keys.First(); - var pkValue = JsonElementToValue(pkData[pkColumn]); - using var cmd = connection.CreateCommand(); cmd.CommandText = $"DELETE FROM {entry.TableName} WHERE {pkColumn} = @pk"; cmd.Parameters.AddWithValue("@pk", pkValue); @@ -180,6 +131,75 @@ private static BoolSyncResult ApplyDelete(SqliteConnection connection, SyncLogEn return new BoolSyncOk(true); } + private static (string PkColumn, object PkValue)? ParsePkValue(string pkValue) + { + var pkData = JsonSerializer.Deserialize>(pkValue); + if (pkData is null || pkData.Count == 0) + { + return null; + } + + var pkColumn = pkData.Keys.First(); + return (pkColumn, JsonElementToValue(pkData[pkColumn])); + } + + /// + /// Validates and deserializes a sync log payload into column/value data. + /// Shared by the insert and update application paths. + /// + private static (Dictionary? Data, BoolSyncResult? Error) ParsePayload( + string? payload, + string operation + ) + { + if (string.IsNullOrEmpty(payload)) + { + return ( + null, + new BoolSyncError(new SyncErrorDatabase($"{operation} requires payload")) + ); + } + + var data = JsonSerializer.Deserialize>(payload); + if (data == null || data.Count == 0) + { + return (null, new BoolSyncError(new SyncErrorDatabase("Invalid payload JSON"))); + } + + return (data, null); + } + + /// + /// Writes row data via INSERT OR REPLACE upsert. + /// Shared by the insert and update application paths. + /// + [SuppressMessage( + "Security", + "CA2100:Review SQL queries for security vulnerabilities", + Justification = "Table names come from internal sync log, not user input" + )] + private static BoolSyncResult UpsertRow( + SqliteConnection connection, + SyncLogEntry entry, + Dictionary data + ) + { + var columns = string.Join(", ", data.Keys); + var parameters = string.Join(", ", data.Keys.Select(k => $"@{k}")); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"INSERT OR REPLACE INTO {entry.TableName} ({columns}) VALUES ({parameters})"; + + foreach (var kvp in data) + { + cmd.Parameters.AddWithValue($"@{kvp.Key}", JsonElementToValue(kvp.Value)); + } + + cmd.ExecuteNonQuery(); + return new BoolSyncOk(true); + } + private static object JsonElementToValue(JsonElement element) => element.ValueKind switch { diff --git a/Sync/Nimblesite.Sync.SQLite/MappingRepository.cs b/Sync/Nimblesite.Sync.SQLite/MappingRepository.cs index 94df1dc8..b5176a0e 100644 --- a/Sync/Nimblesite.Sync.SQLite/MappingRepository.cs +++ b/Sync/Nimblesite.Sync.SQLite/MappingRepository.cs @@ -10,86 +10,111 @@ namespace Nimblesite.Sync.SQLite; public static class MappingRepository { /// - /// Gets mapping state by ID. + /// Creates, configures, and executes a command, converting SqliteException + /// into the supplied error result. Shared by all repository operations. /// - /// SQLite connection. - /// Mapping identifier. - /// Mapping state or null if not found. - public static MappingStateResult GetMappingState(SqliteConnection connection, string mappingId) + private static TResult Execute( + SqliteConnection connection, + Action setSql, + (string Name, object Value)[] parameters, + Func execute, + Func onError + ) { try { using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - SELECT mapping_id, last_synced_version, last_sync_timestamp, records_synced - FROM _sync_mapping_state - WHERE mapping_id = @mappingId - """; - cmd.Parameters.AddWithValue("@mappingId", mappingId); - - using var reader = cmd.ExecuteReader(); - if (!reader.Read()) + setSql(cmd); + foreach (var (name, value) in parameters) { - return new MappingStateOk(null); + cmd.Parameters.AddWithValue(name, value); } - return new MappingStateOk( - new MappingStateEntry( - reader.GetString(0), - reader.GetInt64(1), - reader.GetString(2), - reader.GetInt64(3) - ) - ); + return execute(cmd); } catch (SqliteException ex) { - return new MappingStateError( - new SyncErrorDatabase($"Failed to get mapping state: {ex.Message}") - ); + return onError(ex.Message); } } + /// + /// Maps the current reader row to a MappingStateEntry. + /// + private static MappingStateEntry ReadMappingState(SqliteDataReader reader) => + new(reader.GetString(0), reader.GetInt64(1), reader.GetString(2), reader.GetInt64(3)); + + /// + /// Maps the current reader row to a RecordHashEntry. + /// + private static RecordHashEntry ReadRecordHash(SqliteDataReader reader) => + new(reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3)); + + /// + /// Gets mapping state by ID. + /// + /// SQLite connection. + /// Mapping identifier. + /// Mapping state or null if not found. + public static MappingStateResult GetMappingState( + SqliteConnection connection, + string mappingId + ) => + Execute( + connection, + cmd => + cmd.CommandText = """ + SELECT mapping_id, last_synced_version, last_sync_timestamp, records_synced + FROM _sync_mapping_state + WHERE mapping_id = @mappingId + """, + [("@mappingId", mappingId)], + cmd => + { + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + return new MappingStateOk(null); + } + + return new MappingStateOk(ReadMappingState(reader)); + }, + message => new MappingStateError( + new SyncErrorDatabase($"Failed to get mapping state: {message}") + ) + ); + /// /// Gets all mapping states. /// /// SQLite connection. /// List of mapping states. - public static MappingStateListResult GetAllMappingStates(SqliteConnection connection) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ + public static MappingStateListResult GetAllMappingStates(SqliteConnection connection) => + Execute( + connection, + cmd => + cmd.CommandText = """ SELECT mapping_id, last_synced_version, last_sync_timestamp, records_synced FROM _sync_mapping_state ORDER BY mapping_id - """; - - var states = new List(); - using var reader = cmd.ExecuteReader(); - - while (reader.Read()) + """, + [], + cmd => { - states.Add( - new MappingStateEntry( - reader.GetString(0), - reader.GetInt64(1), - reader.GetString(2), - reader.GetInt64(3) - ) - ); - } + var states = new List(); + using var reader = cmd.ExecuteReader(); - return new MappingStateListOk(states); - } - catch (SqliteException ex) - { - return new MappingStateListError( - new SyncErrorDatabase($"Failed to get mapping states: {ex.Message}") - ); - } - } + while (reader.Read()) + { + states.Add(ReadMappingState(reader)); + } + + return new MappingStateListOk(states); + }, + message => new MappingStateListError( + new SyncErrorDatabase($"Failed to get mapping states: {message}") + ) + ); /// /// Upserts mapping state. @@ -100,34 +125,33 @@ ORDER BY mapping_id public static BoolSyncResult UpsertMappingState( SqliteConnection connection, MappingStateEntry state - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ + ) => + Execute( + connection, + cmd => + cmd.CommandText = """ INSERT INTO _sync_mapping_state (mapping_id, last_synced_version, last_sync_timestamp, records_synced) VALUES (@mappingId, @version, @timestamp, @records) ON CONFLICT (mapping_id) DO UPDATE SET last_synced_version = @version, last_sync_timestamp = @timestamp, records_synced = @records - """; - cmd.Parameters.AddWithValue("@mappingId", state.MappingId); - cmd.Parameters.AddWithValue("@version", state.LastSyncedVersion); - cmd.Parameters.AddWithValue("@timestamp", state.LastSyncTimestamp); - cmd.Parameters.AddWithValue("@records", state.RecordsSynced); - cmd.ExecuteNonQuery(); - - return new BoolSyncOk(true); - } - catch (SqliteException ex) - { - return new BoolSyncError( - new SyncErrorDatabase($"Failed to upsert mapping state: {ex.Message}") - ); - } - } + """, + [ + ("@mappingId", state.MappingId), + ("@version", state.LastSyncedVersion), + ("@timestamp", state.LastSyncTimestamp), + ("@records", state.RecordsSynced), + ], + cmd => + { + cmd.ExecuteNonQuery(); + return new BoolSyncOk(true); + }, + message => new BoolSyncError( + new SyncErrorDatabase($"Failed to upsert mapping state: {message}") + ) + ); /// /// Deletes mapping state. @@ -135,24 +159,24 @@ ON CONFLICT (mapping_id) DO UPDATE SET /// SQLite connection. /// Mapping identifier. /// Success or error. - public static BoolSyncResult DeleteMappingState(SqliteConnection connection, string mappingId) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM _sync_mapping_state WHERE mapping_id = @mappingId"; - cmd.Parameters.AddWithValue("@mappingId", mappingId); - cmd.ExecuteNonQuery(); - - return new BoolSyncOk(true); - } - catch (SqliteException ex) - { - return new BoolSyncError( - new SyncErrorDatabase($"Failed to delete mapping state: {ex.Message}") - ); - } - } + public static BoolSyncResult DeleteMappingState( + SqliteConnection connection, + string mappingId + ) => + Execute( + connection, + cmd => + cmd.CommandText = "DELETE FROM _sync_mapping_state WHERE mapping_id = @mappingId", + [("@mappingId", mappingId)], + cmd => + { + cmd.ExecuteNonQuery(); + return new BoolSyncOk(true); + }, + message => new BoolSyncError( + new SyncErrorDatabase($"Failed to delete mapping state: {message}") + ) + ); /// /// Gets record hash by mapping ID and source PK. @@ -165,41 +189,30 @@ public static RecordHashResult GetRecordHash( SqliteConnection connection, string mappingId, string sourcePk - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ + ) => + Execute( + connection, + cmd => + cmd.CommandText = """ SELECT mapping_id, source_pk, payload_hash, synced_at FROM _sync_record_hashes WHERE mapping_id = @mappingId AND source_pk = @sourcePk - """; - cmd.Parameters.AddWithValue("@mappingId", mappingId); - cmd.Parameters.AddWithValue("@sourcePk", sourcePk); - - using var reader = cmd.ExecuteReader(); - if (!reader.Read()) + """, + [("@mappingId", mappingId), ("@sourcePk", sourcePk)], + cmd => { - return new RecordHashOk(null); - } + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + { + return new RecordHashOk(null); + } - return new RecordHashOk( - new RecordHashEntry( - reader.GetString(0), - reader.GetString(1), - reader.GetString(2), - reader.GetString(3) - ) - ); - } - catch (SqliteException ex) - { - return new RecordHashError( - new SyncErrorDatabase($"Failed to get record hash: {ex.Message}") - ); - } - } + return new RecordHashOk(ReadRecordHash(reader)); + }, + message => new RecordHashError( + new SyncErrorDatabase($"Failed to get record hash: {message}") + ) + ); /// /// Upserts record hash. @@ -207,33 +220,35 @@ FROM _sync_record_hashes /// SQLite connection. /// Record hash to save. /// Success or error. - public static BoolSyncResult UpsertRecordHash(SqliteConnection connection, RecordHashEntry hash) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ + public static BoolSyncResult UpsertRecordHash( + SqliteConnection connection, + RecordHashEntry hash + ) => + Execute( + connection, + cmd => + cmd.CommandText = """ INSERT INTO _sync_record_hashes (mapping_id, source_pk, payload_hash, synced_at) VALUES (@mappingId, @sourcePk, @hash, @syncedAt) ON CONFLICT (mapping_id, source_pk) DO UPDATE SET payload_hash = @hash, synced_at = @syncedAt - """; - cmd.Parameters.AddWithValue("@mappingId", hash.MappingId); - cmd.Parameters.AddWithValue("@sourcePk", hash.SourcePk); - cmd.Parameters.AddWithValue("@hash", hash.PayloadHash); - cmd.Parameters.AddWithValue("@syncedAt", hash.SyncedAt); - cmd.ExecuteNonQuery(); - - return new BoolSyncOk(true); - } - catch (SqliteException ex) - { - return new BoolSyncError( - new SyncErrorDatabase($"Failed to upsert record hash: {ex.Message}") - ); - } - } + """, + [ + ("@mappingId", hash.MappingId), + ("@sourcePk", hash.SourcePk), + ("@hash", hash.PayloadHash), + ("@syncedAt", hash.SyncedAt), + ], + cmd => + { + cmd.ExecuteNonQuery(); + return new BoolSyncOk(true); + }, + message => new BoolSyncError( + new SyncErrorDatabase($"Failed to upsert record hash: {message}") + ) + ); /// /// Deletes record hash. @@ -246,28 +261,24 @@ public static BoolSyncResult DeleteRecordHash( SqliteConnection connection, string mappingId, string sourcePk - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ + ) => + Execute( + connection, + cmd => + cmd.CommandText = """ DELETE FROM _sync_record_hashes WHERE mapping_id = @mappingId AND source_pk = @sourcePk - """; - cmd.Parameters.AddWithValue("@mappingId", mappingId); - cmd.Parameters.AddWithValue("@sourcePk", sourcePk); - cmd.ExecuteNonQuery(); - - return new BoolSyncOk(true); - } - catch (SqliteException ex) - { - return new BoolSyncError( - new SyncErrorDatabase($"Failed to delete record hash: {ex.Message}") - ); - } - } + """, + [("@mappingId", mappingId), ("@sourcePk", sourcePk)], + cmd => + { + cmd.ExecuteNonQuery(); + return new BoolSyncOk(true); + }, + message => new BoolSyncError( + new SyncErrorDatabase($"Failed to delete record hash: {message}") + ) + ); /// /// Deletes all record hashes for a mapping. @@ -278,24 +289,17 @@ DELETE FROM _sync_record_hashes public static IntSyncResult DeleteRecordHashesByMapping( SqliteConnection connection, string mappingId - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM _sync_record_hashes WHERE mapping_id = @mappingId"; - cmd.Parameters.AddWithValue("@mappingId", mappingId); - var count = cmd.ExecuteNonQuery(); - - return new IntSyncOk(count); - } - catch (SqliteException ex) - { - return new IntSyncError( - new SyncErrorDatabase($"Failed to delete record hashes: {ex.Message}") - ); - } - } + ) => + Execute( + connection, + cmd => + cmd.CommandText = "DELETE FROM _sync_record_hashes WHERE mapping_id = @mappingId", + [("@mappingId", mappingId)], + cmd => new IntSyncOk(cmd.ExecuteNonQuery()), + message => new IntSyncError( + new SyncErrorDatabase($"Failed to delete record hashes: {message}") + ) + ); /// /// Counts record hashes for a mapping. @@ -303,23 +307,18 @@ string mappingId /// SQLite connection. /// Mapping identifier. /// Count or error. - public static LongSyncResult CountRecordHashes(SqliteConnection connection, string mappingId) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = - "SELECT COUNT(*) FROM _sync_record_hashes WHERE mapping_id = @mappingId"; - cmd.Parameters.AddWithValue("@mappingId", mappingId); - var count = Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); - - return new LongSyncOk(count); - } - catch (SqliteException ex) - { - return new LongSyncError( - new SyncErrorDatabase($"Failed to count record hashes: {ex.Message}") - ); - } - } + public static LongSyncResult CountRecordHashes(SqliteConnection connection, string mappingId) => + Execute( + connection, + cmd => + cmd.CommandText = + "SELECT COUNT(*) FROM _sync_record_hashes WHERE mapping_id = @mappingId", + [("@mappingId", mappingId)], + cmd => new LongSyncOk( + Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture) + ), + message => new LongSyncError( + new SyncErrorDatabase($"Failed to count record hashes: {message}") + ) + ); } diff --git a/Sync/Nimblesite.Sync.SQLite/SqliteConnectionSyncExtensions.cs b/Sync/Nimblesite.Sync.SQLite/SqliteConnectionSyncExtensions.cs index da7a6eea..e9dc3336 100644 --- a/Sync/Nimblesite.Sync.SQLite/SqliteConnectionSyncExtensions.cs +++ b/Sync/Nimblesite.Sync.SQLite/SqliteConnectionSyncExtensions.cs @@ -13,34 +13,8 @@ public static class SqliteConnectionSyncExtensions /// /// Gets all subscriptions from _sync_subscriptions. /// - public static SubscriptionListResult GetAllSubscriptions(this SqliteConnection connection) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - SELECT subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at - FROM _sync_subscriptions - ORDER BY created_at ASC - """; - - var subscriptions = new List(); - using var reader = cmd.ExecuteReader(); - - while (reader.Read()) - { - subscriptions.Add(ReadSubscription(reader)); - } - - return new SubscriptionListOk(subscriptions); - } - catch (SqliteException ex) - { - return new SubscriptionListError( - new SyncErrorDatabase($"Failed to get subscriptions: {ex.Message}") - ); - } - } + public static SubscriptionListResult GetAllSubscriptions(this SqliteConnection connection) => + SubscriptionRepository.GetAll(connection: connection); /// /// Gets subscriptions for a specific table. @@ -65,7 +39,7 @@ FROM _sync_subscriptions while (reader.Read()) { - subscriptions.Add(ReadSubscription(reader)); + subscriptions.Add(SubscriptionRepository.ReadSubscription(reader: reader)); } return new SubscriptionListOk(subscriptions); @@ -84,41 +58,7 @@ FROM _sync_subscriptions public static BoolSyncResult InsertSubscription( this SqliteConnection connection, SyncSubscription subscription - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - INSERT INTO _sync_subscriptions - (subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at) - VALUES - (@subscriptionId, @originId, @subscriptionType, @tableName, @filter, @createdAt, @expiresAt) - """; - cmd.Parameters.AddWithValue("@subscriptionId", subscription.SubscriptionId); - cmd.Parameters.AddWithValue("@originId", subscription.OriginId); - cmd.Parameters.AddWithValue( - "@subscriptionType", - subscription.Type.ToString().ToLowerInvariant() - ); - cmd.Parameters.AddWithValue("@tableName", subscription.TableName); - cmd.Parameters.AddWithValue("@filter", subscription.Filter ?? (object)DBNull.Value); - cmd.Parameters.AddWithValue("@createdAt", subscription.CreatedAt); - cmd.Parameters.AddWithValue( - "@expiresAt", - subscription.ExpiresAt ?? (object)DBNull.Value - ); - - cmd.ExecuteNonQuery(); - return new BoolSyncOk(true); - } - catch (SqliteException ex) - { - return new BoolSyncError( - new SyncErrorDatabase($"Failed to insert subscription: {ex.Message}") - ); - } - } + ) => SubscriptionRepository.Insert(connection: connection, subscription: subscription); /// /// Deletes a subscription by ID. @@ -126,25 +66,7 @@ INSERT INTO _sync_subscriptions public static BoolSyncResult DeleteSubscription( this SqliteConnection connection, string subscriptionId - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = - "DELETE FROM _sync_subscriptions WHERE subscription_id = @subscriptionId"; - cmd.Parameters.AddWithValue("@subscriptionId", subscriptionId); - - cmd.ExecuteNonQuery(); - return new BoolSyncOk(true); - } - catch (SqliteException ex) - { - return new BoolSyncError( - new SyncErrorDatabase($"Failed to delete subscription: {ex.Message}") - ); - } - } + ) => SubscriptionRepository.Delete(connection: connection, subscriptionId: subscriptionId); /// /// Deletes all subscriptions for an origin. @@ -152,24 +74,7 @@ string subscriptionId public static IntSyncResult DeleteSubscriptionsByOrigin( this SqliteConnection connection, string originId - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM _sync_subscriptions WHERE origin_id = @originId"; - cmd.Parameters.AddWithValue("@originId", originId); - - var deleted = cmd.ExecuteNonQuery(); - return new IntSyncOk(deleted); - } - catch (SqliteException ex) - { - return new IntSyncError( - new SyncErrorDatabase($"Failed to delete subscriptions: {ex.Message}") - ); - } - } + ) => SubscriptionRepository.DeleteByOrigin(connection: connection, originId: originId); /// /// Deletes expired subscriptions. @@ -177,27 +82,11 @@ string originId public static IntSyncResult DeleteExpiredSubscriptions( this SqliteConnection connection, string currentTimestamp - ) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - DELETE FROM _sync_subscriptions - WHERE expires_at IS NOT NULL AND expires_at < @currentTimestamp - """; - cmd.Parameters.AddWithValue("@currentTimestamp", currentTimestamp); - - var deleted = cmd.ExecuteNonQuery(); - return new IntSyncOk(deleted); - } - catch (SqliteException ex) - { - return new IntSyncError( - new SyncErrorDatabase($"Failed to delete expired subscriptions: {ex.Message}") - ); - } - } + ) => + SubscriptionRepository.DeleteExpired( + connection: connection, + currentTimestamp: currentTimestamp + ); // === Tombstone Operations (Spec Section 13) === @@ -377,24 +266,4 @@ IEnumerable originIds ); } } - - private static SyncSubscription ReadSubscription(SqliteDataReader reader) => - new( - SubscriptionId: reader.GetString(0), - OriginId: reader.GetString(1), - Type: ParseSubscriptionType(reader.GetString(2)), - TableName: reader.GetString(3), - Filter: reader.IsDBNull(4) ? null : reader.GetString(4), - CreatedAt: reader.GetString(5), - ExpiresAt: reader.IsDBNull(6) ? null : reader.GetString(6) - ); - - private static SubscriptionType ParseSubscriptionType(string type) => - type.ToLowerInvariant() switch - { - "record" => SubscriptionType.Record, - "table" => SubscriptionType.Table, - "query" => SubscriptionType.Query, - _ => SubscriptionType.Table, - }; } diff --git a/Sync/Nimblesite.Sync.SQLite/SubscriptionRepository.cs b/Sync/Nimblesite.Sync.SQLite/SubscriptionRepository.cs index f99e80dd..aaa0bb4a 100644 --- a/Sync/Nimblesite.Sync.SQLite/SubscriptionRepository.cs +++ b/Sync/Nimblesite.Sync.SQLite/SubscriptionRepository.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Data.Sqlite; namespace Nimblesite.Sync.SQLite; @@ -13,34 +14,15 @@ public static class SubscriptionRepository /// /// SQLite connection. /// List of subscriptions or database error. - public static SubscriptionListResult GetAll(SqliteConnection connection) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - SELECT subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at - FROM _sync_subscriptions - ORDER BY created_at ASC - """; - - var subscriptions = new List(); - using var reader = cmd.ExecuteReader(); - - while (reader.Read()) - { - subscriptions.Add(ReadSubscription(reader)); - } - - return new SubscriptionListOk(subscriptions); - } - catch (SqliteException ex) - { - return new SubscriptionListError( - new SyncErrorDatabase($"Failed to get subscriptions: {ex.Message}") - ); - } - } + public static SubscriptionListResult GetAll(SqliteConnection connection) => + QueryList( + connection, + """ + SELECT subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at + FROM _sync_subscriptions + ORDER BY created_at ASC + """ + ); /// /// Gets a subscription by ID. @@ -83,36 +65,20 @@ FROM _sync_subscriptions /// SQLite connection. /// Table name to filter by. /// List of subscriptions for the table or database error. - public static SubscriptionListResult GetByTable(SqliteConnection connection, string tableName) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - SELECT subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at - FROM _sync_subscriptions - WHERE table_name = @tableName - ORDER BY created_at ASC - """; - cmd.Parameters.AddWithValue("@tableName", tableName); - - var subscriptions = new List(); - using var reader = cmd.ExecuteReader(); - - while (reader.Read()) - { - subscriptions.Add(ReadSubscription(reader)); - } - - return new SubscriptionListOk(subscriptions); - } - catch (SqliteException ex) - { - return new SubscriptionListError( - new SyncErrorDatabase($"Failed to get subscriptions: {ex.Message}") - ); - } - } + public static SubscriptionListResult GetByTable( + SqliteConnection connection, + string tableName + ) => + QueryList( + connection, + """ + SELECT subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at + FROM _sync_subscriptions + WHERE table_name = @tableName + ORDER BY created_at ASC + """, + cmd => cmd.Parameters.AddWithValue("@tableName", tableName) + ); /// /// Gets subscriptions for a specific origin. @@ -120,36 +86,20 @@ ORDER BY created_at ASC /// SQLite connection. /// Origin ID to filter by. /// List of subscriptions for the origin or database error. - public static SubscriptionListResult GetByOrigin(SqliteConnection connection, string originId) - { - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = """ - SELECT subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at - FROM _sync_subscriptions - WHERE origin_id = @originId - ORDER BY created_at ASC - """; - cmd.Parameters.AddWithValue("@originId", originId); - - var subscriptions = new List(); - using var reader = cmd.ExecuteReader(); - - while (reader.Read()) - { - subscriptions.Add(ReadSubscription(reader)); - } - - return new SubscriptionListOk(subscriptions); - } - catch (SqliteException ex) - { - return new SubscriptionListError( - new SyncErrorDatabase($"Failed to get subscriptions: {ex.Message}") - ); - } - } + public static SubscriptionListResult GetByOrigin( + SqliteConnection connection, + string originId + ) => + QueryList( + connection, + """ + SELECT subscription_id, origin_id, subscription_type, table_name, filter, created_at, expires_at + FROM _sync_subscriptions + WHERE origin_id = @originId + ORDER BY created_at ASC + """, + cmd => cmd.Parameters.AddWithValue("@originId", originId) + ); /// /// Inserts a subscription into _sync_subscriptions. @@ -271,7 +221,42 @@ WHERE expires_at IS NOT NULL AND expires_at < @currentTimestamp } } - private static SyncSubscription ReadSubscription(SqliteDataReader reader) => + [SuppressMessage( + "Security", + "CA2100:Review SQL queries for security vulnerabilities", + Justification = "SQL comes from internal constant literals, not user input" + )] + private static SubscriptionListResult QueryList( + SqliteConnection connection, + string sql, + Action? bindParameters = null + ) + { + try + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + bindParameters?.Invoke(cmd); + + var subscriptions = new List(); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) + { + subscriptions.Add(ReadSubscription(reader)); + } + + return new SubscriptionListOk(subscriptions); + } + catch (SqliteException ex) + { + return new SubscriptionListError( + new SyncErrorDatabase($"Failed to get subscriptions: {ex.Message}") + ); + } + } + + internal static SyncSubscription ReadSubscription(SqliteDataReader reader) => new( SubscriptionId: reader.GetString(0), OriginId: reader.GetString(1), @@ -282,7 +267,7 @@ private static SyncSubscription ReadSubscription(SqliteDataReader reader) => ExpiresAt: reader.IsDBNull(6) ? null : reader.GetString(6) ); - private static SubscriptionType ParseSubscriptionType(string type) => + internal static SubscriptionType ParseSubscriptionType(string type) => type.ToLowerInvariant() switch { "record" => SubscriptionType.Record, diff --git a/Sync/Nimblesite.Sync.Tests/BatchManagerTests.cs b/Sync/Nimblesite.Sync.Tests/BatchManagerTests.cs index 4d9e7317..b4f9c6a5 100644 --- a/Sync/Nimblesite.Sync.Tests/BatchManagerTests.cs +++ b/Sync/Nimblesite.Sync.Tests/BatchManagerTests.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging.Abstractions; -using Outcome; namespace Nimblesite.Sync.Tests; @@ -18,7 +17,7 @@ public void FetchBatch_EmptyLog_ReturnsEmptyBatch() _logger ); - var batch = AssertSuccess(result); + var batch = TestDb.AssertSuccess(result); Assert.Empty(batch.Changes); Assert.Equal(0, batch.FromVersion); Assert.Equal(0, batch.ToVersion); @@ -52,7 +51,7 @@ public void FetchBatch_WithChanges_ReturnsBatch() _logger ); - var batch = AssertSuccess(result); + var batch = TestDb.AssertSuccess(result); Assert.Equal(2, batch.Changes.Count); Assert.Equal(0, batch.FromVersion); Assert.Equal(2, batch.ToVersion); @@ -81,7 +80,7 @@ public void FetchBatch_ExceedsBatchSize_HasMoreTrue() _logger ); - var batch = AssertSuccess(result); + var batch = TestDb.AssertSuccess(result); Assert.Equal(3, batch.Changes.Count); Assert.True(batch.HasMore); Assert.Equal(3, batch.ToVersion); @@ -109,7 +108,7 @@ public void FetchBatch_FromVersion_SkipsOlderEntries() _logger ); - var batch = AssertSuccess(result); + var batch = TestDb.AssertSuccess(result); Assert.Equal(2, batch.Changes.Count); Assert.Equal(4, batch.Changes[0].Version); Assert.Equal(5, batch.Changes[1].Version); @@ -148,17 +147,11 @@ public void ProcessAllBatches_ProcessesMultipleBatches() _logger ); - var totalApplied = AssertSuccess(result); + var totalApplied = TestDb.AssertSuccess(result); Assert.Equal(10, totalApplied); Assert.Equal(4, appliedBatches.Count); Assert.Equal(10, lastVersion); } - private static T AssertSuccess(Result result) - { - Assert.IsType.Ok>(result); - return ((Result.Ok)result).Value; - } - public void Dispose() => _db.Dispose(); } diff --git a/Sync/Nimblesite.Sync.Tests/ChangeApplierTests.cs b/Sync/Nimblesite.Sync.Tests/ChangeApplierTests.cs index 3fbb4040..7275721b 100644 --- a/Sync/Nimblesite.Sync.Tests/ChangeApplierTests.cs +++ b/Sync/Nimblesite.Sync.Tests/ChangeApplierTests.cs @@ -1,6 +1,5 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; -using Outcome; namespace Nimblesite.Sync.Tests; @@ -53,7 +52,7 @@ public void ApplyBatch_SkipsOwnOriginChanges() Logger ); - var applyResult = AssertSuccess(result); + var applyResult = TestDb.AssertSuccess(result); Assert.Single(appliedEntries); Assert.Equal("other-origin", appliedEntries[0].Origin); Assert.Equal(1, applyResult.AppliedCount); @@ -111,7 +110,7 @@ public void ApplyBatch_DefersAndRetriesFkViolations() Logger ); - var applyResult = AssertSuccess(result); + var applyResult = TestDb.AssertSuccess(result); Assert.Equal(2, applyResult.AppliedCount); Assert.Equal(2, attemptCounts[1]); // Child tried twice Assert.Equal(1, attemptCounts[2]); // Parent tried once @@ -184,7 +183,7 @@ public void ApplyBatch_WithRealSqlite_HandlesInsertUpdateDelete() var result = ChangeApplier.ApplyBatch(batch, "my-origin", 3, ApplyToDb, Logger); - var applyResult = AssertSuccess(result); + var applyResult = TestDb.AssertSuccess(result); Assert.Equal(2, applyResult.AppliedCount); // Verify final state @@ -270,11 +269,5 @@ private BoolSyncResult ApplyToDb(SyncLogEntry entry) } } - private static T AssertSuccess(Result result) - { - Assert.IsType.Ok>(result); - return ((Result.Ok)result).Value; - } - public void Dispose() => _db.Dispose(); } diff --git a/Sync/Nimblesite.Sync.Tests/LqlExpressionEvaluatorTests.cs b/Sync/Nimblesite.Sync.Tests/LqlExpressionEvaluatorTests.cs index a2da08ab..030ce5bf 100644 --- a/Sync/Nimblesite.Sync.Tests/LqlExpressionEvaluatorTests.cs +++ b/Sync/Nimblesite.Sync.Tests/LqlExpressionEvaluatorTests.cs @@ -177,7 +177,7 @@ public void MappingEngine_LqlTransform_AppliesUppercase() }; var mapping = CreateMappingWithColumns("user-mapping", "User", "Customer", columnMappings); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var entry = CreateEntry("User", """{"Id":"u1"}""", """{"Id":"u1","Name":"alice"}"""); var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); @@ -204,7 +204,7 @@ public void MappingEngine_LqlTransform_ConcatNamesForDifferentSchema() }; var mapping = CreateMappingWithColumns("user-mapping", "User", "Customer", columnMappings); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var entry = CreateEntry( "User", """{"Id":"u1"}""", @@ -230,7 +230,7 @@ public void MappingEngine_LqlTransform_ExtractDomainFromEmail() }; var mapping = CreateMappingWithColumns("user-mapping", "User", "Customer", columnMappings); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var entry = CreateEntry( "User", """{"Id":"u1"}""", @@ -260,7 +260,7 @@ public void MappingEngine_LqlTransform_NormalizeAndFormat() }; var mapping = CreateMappingWithColumns("user-mapping", "User", "Customer", columnMappings); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var entry = CreateEntry( "User", """{"Id":"u1"}""", @@ -292,7 +292,7 @@ public void MappingEngine_LqlTransform_FormatDatesForDifferentSystem() }; var mapping = CreateMappingWithColumns("user-mapping", "User", "Customer", columnMappings); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var entry = CreateEntry( "User", """{"Id":"u1"}""", @@ -325,7 +325,7 @@ public void MappingEngine_LqlTransform_MultipleTransformsInSameMapping() }; var mapping = CreateMappingWithColumns("user-mapping", "User", "Customer", columnMappings); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var entry = CreateEntry( "User", """{"Id":"u1"}""", @@ -353,7 +353,7 @@ public void MappingEngine_LqlTransform_WithConstantsAndLql() }; var mapping = CreateMappingWithColumns("user-mapping", "User", "Customer", columnMappings); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var entry = CreateEntry("User", """{"Id":"u1"}""", """{"Id":"u1","Name":"alice"}"""); var result = MappingEngine.ApplyMapping(entry, config, MappingDirection.Push, _logger); @@ -407,7 +407,7 @@ public void E2E_MobileToServerSchemaTransform() "ServerUser", columnMappings ); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var mobileEntry = CreateEntry( "MobileUser", @@ -469,7 +469,7 @@ public void E2E_LegacyCRMToModernERPTransform() "ModernCustomer", columnMappings ); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var legacyEntry = CreateEntry( "LegacyCustomer", @@ -521,7 +521,7 @@ public void E2E_OrderToAuditLogTransform() "AuditLog", columnMappings ); - var config = new SyncMappingConfig("1.0", UnmappedTableBehavior.Strict, [mapping]); + var config = CreateStrictConfig(mapping); var orderEntry = CreateEntry( "Order", @@ -619,5 +619,8 @@ private static SyncLogEntry CreateEntry(string table, string pk, string? payload Timestamp: "2024-01-01T00:00:00Z" ); + private static SyncMappingConfig CreateStrictConfig(TableMapping mapping) => + new("1.0", UnmappedTableBehavior.Strict, [mapping]); + #endregion } diff --git a/Sync/Nimblesite.Sync.Tests/SyncCoordinatorTests.cs b/Sync/Nimblesite.Sync.Tests/SyncCoordinatorTests.cs index 98255bb6..dcf856e5 100644 --- a/Sync/Nimblesite.Sync.Tests/SyncCoordinatorTests.cs +++ b/Sync/Nimblesite.Sync.Tests/SyncCoordinatorTests.cs @@ -2,6 +2,14 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using PullResultResult = Outcome.Result< + Nimblesite.Sync.Core.PullResult, + Nimblesite.Sync.Core.SyncError +>; +using PushResultResult = Outcome.Result< + Nimblesite.Sync.Core.PushResult, + Nimblesite.Sync.Core.SyncError +>; namespace Nimblesite.Sync.Tests; @@ -38,17 +46,7 @@ public void Pull_EmptyServer_ReturnsZeroChanges() { var lastVersion = 0L; - var result = SyncCoordinator.Pull( - ClientOrigin, - lastVersion, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger - ); + var result = PullFromServer(fromVersion: lastVersion, batch: new BatchConfig(100)); Assert.IsType(result); var pull = ((PullResultOk)result).Value; @@ -64,17 +62,7 @@ public void Pull_ServerHasChanges_AppliesAll() InsertPerson(_serverDb, "p1", "Alice"); InsertPerson(_serverDb, "p2", "Bob"); - var result = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger - ); + var result = PullFromServer(fromVersion: 0, batch: new BatchConfig(100)); Assert.IsType(result); var pull = ((PullResultOk)result).Value; @@ -102,17 +90,7 @@ public void Pull_SkipsOwnOriginChanges_EchoPrevention() ClientOrigin ); - var result = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger - ); + var result = PullFromServer(fromVersion: 0, batch: new BatchConfig(100)); Assert.IsType(result); var pull = ((PullResultOk)result).Value; @@ -131,17 +109,7 @@ public void Pull_MultipleBatches_ProcessesAll() InsertPerson(_serverDb, $"p{i}", $"Person {i}"); } - var result = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(10), // Small batches - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger - ); + var result = PullFromServer(fromVersion: 0, batch: new BatchConfig(10)); // Small batches Assert.IsType(result); var pull = ((PullResultOk)result).Value; @@ -161,17 +129,7 @@ public void Pull_WithForeignKeys_RetriesDeferred() InsertParent(_serverDb, "parent1", "Parent One"); InsertChild(_serverDb, "child1", "parent1", "Child One"); - var result = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(100, 3), // Allow retries - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger - ); + var result = PullFromServer(fromVersion: 0, batch: new BatchConfig(100, 3)); // Allow retries Assert.IsType(result); var pull = ((PullResultOk)result).Value; @@ -185,15 +143,16 @@ public void Pull_WithForeignKeys_RetriesDeferred() public void Pull_TriggerSuppressionFailure_ReturnsError() { var result = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => new BoolSyncError(new SyncErrorDatabase("Suppression failed")), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger + myOriginId: ClientOrigin, + lastSyncedVersion: 0, + config: new BatchConfig(100), + fetchRemoteChanges: FetchServerChanges, + applyLocalChange: ApplyClientChange, + enableTriggerSuppression: () => + new BoolSyncError(new SyncErrorDatabase("Suppression failed")), + disableTriggerSuppression: DisableClientSuppression, + updateLastSyncedVersion: SaveClientSyncVersion, + logger: Logger ); Assert.IsType(result); @@ -205,15 +164,16 @@ public void Pull_TriggerSuppressionFailure_ReturnsError() public void Pull_FetchError_ReturnsError() { var result = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(100), - (from, limit) => new SyncLogListError(new SyncErrorDatabase("Fetch failed")), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger + myOriginId: ClientOrigin, + lastSyncedVersion: 0, + config: new BatchConfig(100), + fetchRemoteChanges: (from, limit) => + new SyncLogListError(new SyncErrorDatabase("Fetch failed")), + applyLocalChange: ApplyClientChange, + enableTriggerSuppression: EnableClientSuppression, + disableTriggerSuppression: DisableClientSuppression, + updateLastSyncedVersion: SaveClientSyncVersion, + logger: Logger ); Assert.IsType(result); @@ -226,15 +186,15 @@ public void Pull_UpdatesVersion_Correctly() var capturedVersion = 0L; _ = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => capturedVersion = v, - Logger + myOriginId: ClientOrigin, + lastSyncedVersion: 0, + config: new BatchConfig(100), + fetchRemoteChanges: FetchServerChanges, + applyLocalChange: ApplyClientChange, + enableTriggerSuppression: EnableClientSuppression, + disableTriggerSuppression: DisableClientSuppression, + updateLastSyncedVersion: v => capturedVersion = v, + logger: Logger ); Assert.True(capturedVersion > 0); @@ -247,14 +207,7 @@ public void Pull_UpdatesVersion_Correctly() [Fact] public void Push_EmptyClient_ReturnsZeroChanges() { - var result = SyncCoordinator.Push( - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_clientDb, from, limit), - changes => ApplyChangesToTarget(_serverDb, changes, ServerOrigin), - v => SetLastPushVersion(_clientDb, v), - Logger - ); + var result = PushFromClient(fromVersion: 0, batch: new BatchConfig(100)); Assert.IsType(result); var push = ((PushResultOk)result).Value; @@ -267,14 +220,7 @@ public void Push_ClientHasChanges_PushesAll() InsertPerson(_clientDb, "p1", "Charlie"); InsertPerson(_clientDb, "p2", "Diana"); - var result = SyncCoordinator.Push( - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_clientDb, from, limit), - changes => ApplyChangesToTarget(_serverDb, changes, ServerOrigin), - v => SetLastPushVersion(_clientDb, v), - Logger - ); + var result = PushFromClient(fromVersion: 0, batch: new BatchConfig(100)); Assert.IsType(result); var push = ((PushResultOk)result).Value; @@ -292,14 +238,7 @@ public void Push_MultipleBatches_PushesAll() InsertPerson(_clientDb, $"p{i}", $"Person {i}"); } - var result = SyncCoordinator.Push( - 0, - new BatchConfig(10), - (from, limit) => FetchChanges(_clientDb, from, limit), - changes => ApplyChangesToTarget(_serverDb, changes, ServerOrigin), - v => SetLastPushVersion(_clientDb, v), - Logger - ); + var result = PushFromClient(fromVersion: 0, batch: new BatchConfig(10)); Assert.IsType(result); var push = ((PushResultOk)result).Value; @@ -312,12 +251,12 @@ public void Push_SendFailure_ReturnsError() InsertPerson(_clientDb, "p1", "Alice"); var result = SyncCoordinator.Push( - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_clientDb, from, limit), - changes => new BoolSyncError(new SyncErrorDatabase("Send failed")), - v => SetLastPushVersion(_clientDb, v), - Logger + lastPushedVersion: 0, + config: new BatchConfig(100), + fetchLocalChanges: FetchClientChanges, + sendToRemote: changes => new BoolSyncError(new SyncErrorDatabase("Send failed")), + updateLastPushedVersion: SaveClientPushVersion, + logger: Logger ); Assert.IsType(result); @@ -337,19 +276,19 @@ public void Sync_PullThenPush_Both() InsertPerson(_clientDb, "c1", "ClientBob"); var result = SyncCoordinator.Sync( - ClientOrigin, - 0, - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - (from, limit) => FetchChanges(_clientDb, from, limit), - changes => ApplyChangesToTarget(_serverDb, changes, ServerOrigin), - v => SetLastPushVersion(_clientDb, v), - Logger + myOriginId: ClientOrigin, + lastServerVersion: 0, + lastPushVersion: 0, + config: new BatchConfig(100), + fetchRemoteChanges: FetchServerChanges, + applyLocalChange: ApplyClientChange, + enableTriggerSuppression: EnableClientSuppression, + disableTriggerSuppression: DisableClientSuppression, + updateLastServerVersion: SaveClientSyncVersion, + fetchLocalChanges: FetchClientChanges, + sendToRemote: SendChangesToServer, + updateLastPushVersion: SaveClientPushVersion, + logger: Logger ); Assert.IsType(result); @@ -374,14 +313,15 @@ public void Sync_PullFailure_ReturnsError() 0, 0, new BatchConfig(100), - (from, limit) => new SyncLogListError(new SyncErrorDatabase("Pull failed")), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - (from, limit) => FetchChanges(_clientDb, from, limit), - changes => ApplyChangesToTarget(_serverDb, changes, ServerOrigin), - v => SetLastPushVersion(_clientDb, v), + fetchRemoteChanges: (from, limit) => + new SyncLogListError(new SyncErrorDatabase("Pull failed")), + applyLocalChange: ApplyClientChange, + enableTriggerSuppression: EnableClientSuppression, + disableTriggerSuppression: DisableClientSuppression, + updateLastServerVersion: SaveClientSyncVersion, + fetchLocalChanges: FetchClientChanges, + sendToRemote: SendChangesToServer, + updateLastPushVersion: SaveClientPushVersion, Logger ); @@ -399,14 +339,14 @@ public void Sync_PushFailure_AfterSuccessfulPull_ReturnsError() 0, 0, new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - (from, limit) => FetchChanges(_clientDb, from, limit), - changes => new BoolSyncError(new SyncErrorDatabase("Push failed")), - v => SetLastPushVersion(_clientDb, v), + fetchRemoteChanges: FetchServerChanges, + applyLocalChange: ApplyClientChange, + enableTriggerSuppression: EnableClientSuppression, + disableTriggerSuppression: DisableClientSuppression, + updateLastServerVersion: SaveClientSyncVersion, + fetchLocalChanges: FetchClientChanges, + sendToRemote: changes => new BoolSyncError(new SyncErrorDatabase("Push failed")), + updateLastPushVersion: SaveClientPushVersion, Logger ); @@ -420,17 +360,7 @@ public void Sync_IncrementalSync_OnlyNewChanges() { // Initial sync InsertPerson(_serverDb, "s1", "ServerAlice"); - var result1 = SyncCoordinator.Pull( - ClientOrigin, - 0, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger - ); + var result1 = PullFromServer(fromVersion: 0, batch: new BatchConfig(100)); var firstPull = ((PullResultOk)result1).Value; Assert.Equal(1, firstPull.ChangesApplied); @@ -439,17 +369,7 @@ public void Sync_IncrementalSync_OnlyNewChanges() InsertPerson(_serverDb, "s2", "ServerBob"); // Second sync from last version - var result2 = SyncCoordinator.Pull( - ClientOrigin, - firstPull.ToVersion, - new BatchConfig(100), - (from, limit) => FetchChanges(_serverDb, from, limit), - entry => ApplyChange(_clientDb, entry), - () => EnableSuppression(_clientDb), - () => DisableSuppression(_clientDb), - v => SetLastSyncVersion(_clientDb, v), - Logger - ); + var result2 = PullFromServer(fromVersion: firstPull.ToVersion, batch: new BatchConfig(100)); var secondPull = ((PullResultOk)result2).Value; Assert.Equal(1, secondPull.ChangesApplied); // Only Bob @@ -460,6 +380,51 @@ public void Sync_IncrementalSync_OnlyNewChanges() #region Helper Methods + // Shared wiring: the delegate bundle bound to the client/server test databases + // that every pull/push test passes to SyncCoordinator. + + private SyncLogListResult FetchServerChanges(long from, int limit) => + FetchChanges(_serverDb, from, limit); + + private SyncLogListResult FetchClientChanges(long from, int limit) => + FetchChanges(_clientDb, from, limit); + + private BoolSyncResult ApplyClientChange(SyncLogEntry entry) => ApplyChange(_clientDb, entry); + + private BoolSyncResult EnableClientSuppression() => EnableSuppression(_clientDb); + + private BoolSyncResult DisableClientSuppression() => DisableSuppression(_clientDb); + + private void SaveClientSyncVersion(long version) => SetLastSyncVersion(_clientDb, version); + + private BoolSyncResult SendChangesToServer(IReadOnlyList changes) => + ApplyChangesToTarget(_serverDb, changes, ServerOrigin); + + private void SaveClientPushVersion(long version) => SetLastPushVersion(_clientDb, version); + + private PullResultResult PullFromServer(long fromVersion, BatchConfig batch) => + SyncCoordinator.Pull( + myOriginId: ClientOrigin, + lastSyncedVersion: fromVersion, + config: batch, + fetchRemoteChanges: FetchServerChanges, + applyLocalChange: ApplyClientChange, + enableTriggerSuppression: EnableClientSuppression, + disableTriggerSuppression: DisableClientSuppression, + updateLastSyncedVersion: SaveClientSyncVersion, + logger: Logger + ); + + private PushResultResult PushFromClient(long fromVersion, BatchConfig batch) => + SyncCoordinator.Push( + lastPushedVersion: fromVersion, + config: batch, + fetchLocalChanges: FetchClientChanges, + sendToRemote: SendChangesToServer, + updateLastPushedVersion: SaveClientPushVersion, + logger: Logger + ); + private static SqliteConnection CreateSyncDatabase(string originId, string dbPath) { var conn = new SqliteConnection($"Data Source={dbPath}"); @@ -762,29 +727,21 @@ INSERT INTO _sync_log (table_name, pk_value, operation, payload, origin, timesta cmd.ExecuteNonQuery(); } - private static string? GetPersonName(SqliteConnection db, string id) + private static string? GetName(SqliteConnection db, string table, string id) { using var cmd = db.CreateCommand(); - cmd.CommandText = "SELECT Name FROM Person WHERE Id = $id"; + cmd.CommandText = $"SELECT Name FROM {table} WHERE Id = $id"; cmd.Parameters.AddWithValue("$id", id); return cmd.ExecuteScalar() as string; } - private static string? GetParentName(SqliteConnection db, string id) - { - using var cmd = db.CreateCommand(); - cmd.CommandText = "SELECT Name FROM Parent WHERE Id = $id"; - cmd.Parameters.AddWithValue("$id", id); - return cmd.ExecuteScalar() as string; - } + private static string? GetPersonName(SqliteConnection db, string id) => + GetName(db, "Person", id); - private static string? GetChildName(SqliteConnection db, string id) - { - using var cmd = db.CreateCommand(); - cmd.CommandText = "SELECT Name FROM Child WHERE Id = $id"; - cmd.Parameters.AddWithValue("$id", id); - return cmd.ExecuteScalar() as string; - } + private static string? GetParentName(SqliteConnection db, string id) => + GetName(db, "Parent", id); + + private static string? GetChildName(SqliteConnection db, string id) => GetName(db, "Child", id); private static void SetLastSyncVersion(SqliteConnection db, long version) { diff --git a/Sync/Nimblesite.Sync.Tests/SyncStateTests.cs b/Sync/Nimblesite.Sync.Tests/SyncStateTests.cs index bd87af93..d841efeb 100644 --- a/Sync/Nimblesite.Sync.Tests/SyncStateTests.cs +++ b/Sync/Nimblesite.Sync.Tests/SyncStateTests.cs @@ -255,10 +255,7 @@ public void BatchApplyResult_CreatesCorrectRecord() [Fact] public void SyncBatch_CreatesCorrectRecord() { - var changes = new List - { - new(1, "T", "PK", SyncOperation.Insert, "{}", "O", "TS"), - }; + var changes = SingleInsertChange(); var batch = new SyncBatch(changes, 0, 1, false); Assert.Single(batch.Changes); @@ -270,10 +267,7 @@ public void SyncBatch_CreatesCorrectRecord() [Fact] public void SyncBatch_HasMore_True() { - var changes = new List - { - new(1, "T", "PK", SyncOperation.Insert, "{}", "O", "TS"), - }; + var changes = SingleInsertChange(); var batch = new SyncBatch(changes, 0, 1, true); Assert.True(batch.HasMore); @@ -282,10 +276,7 @@ public void SyncBatch_HasMore_True() [Fact] public void SyncBatch_WithHash() { - var changes = new List - { - new(1, "T", "PK", SyncOperation.Insert, "{}", "O", "TS"), - }; + var changes = SingleInsertChange(); var batch = new SyncBatch(changes, 0, 1, false, "abc123hash"); Assert.Equal("abc123hash", batch.Hash); @@ -298,4 +289,7 @@ public void ConflictStrategy_HasCorrectValues() Assert.Equal(ConflictStrategy.ServerWins, (ConflictStrategy)1); Assert.Equal(ConflictStrategy.ClientWins, (ConflictStrategy)2); } + + private static IReadOnlyList SingleInsertChange() => + new List { new(1, "T", "PK", SyncOperation.Insert, "{}", "O", "TS") }; } diff --git a/Sync/Nimblesite.Sync.Tests/TestDb.cs b/Sync/Nimblesite.Sync.Tests/TestDb.cs index 525bfac4..fa20bc00 100644 --- a/Sync/Nimblesite.Sync.Tests/TestDb.cs +++ b/Sync/Nimblesite.Sync.Tests/TestDb.cs @@ -1,4 +1,5 @@ using Microsoft.Data.Sqlite; +using Outcome; namespace Nimblesite.Sync.Tests; @@ -136,6 +137,12 @@ private static SyncOperation ParseOperation(string op) => _ => throw new ArgumentException($"Unknown operation: {op}"), }; + internal static T AssertSuccess(Result result) + { + Assert.IsType.Ok>(result); + return ((Result.Ok)result).Value; + } + public void Dispose() { Connection.Dispose(); diff --git a/Tests.Shared/PostgresContainerFixture.cs b/Tests.Shared/PostgresContainerFixture.cs index ce773df6..de8ceb00 100644 --- a/Tests.Shared/PostgresContainerFixture.cs +++ b/Tests.Shared/PostgresContainerFixture.cs @@ -20,15 +20,27 @@ namespace Nimblesite.TestSupport; /// public sealed class PostgresContainerFixture : IAsyncLifetime { - private PostgreSqlContainer _container = null!; - private string _adminConnectionString = null!; + private PostgreSqlContainer? _container; + private string _adminConnectionString = string.Empty; private long _databaseCounter; + // Isolate fixture instances and repeated runs against a persistent native server. + private readonly string _databaseRunId = Guid.NewGuid().ToString("N")[..12]; + /// /// Starts the shared container. Called once per collection fixture lifetime. /// public async Task InitializeAsync() { + // Implements [MIG-TEST-NATIVE-POSTGRES]: use an isolated native server when configured. + if ( + Environment.GetEnvironmentVariable("DATAPROVIDER_POSTGRES_TEST_CONNECTION") is + { Length: > 0 } configured + ) + { + _adminConnectionString = configured; + return; + } // pgvector/pgvector:pg16 is a drop-in superset of postgres:16 that // ships with the pgvector extension preinstalled. Required for any // test that needs vector columns (VectorType). Non-vector tests are @@ -49,7 +61,10 @@ public async Task InitializeAsync() /// public async Task DisposeAsync() { - await _container.DisposeAsync().ConfigureAwait(false); + if (_container is { } container) + { + await container.DisposeAsync().ConfigureAwait(false); + } } /// @@ -130,13 +145,18 @@ private string BuildConnectionString(string dbName) => { Database = dbName, Pooling = false, + // Keep the throwaway container password on opened connections so + // tests can hand connection.ConnectionString to the migrate CLI. + PersistSecurityInfo = true, }.ConnectionString; private string NextDatabaseName(string namePrefix) { var counter = Interlocked.Increment(ref _databaseCounter); - return SanitizeDbName( - string.Create(CultureInfo.InvariantCulture, $"{namePrefix}_{counter:D5}") + var prefix = SanitizeDbName(namePrefix); + return string.Create( + CultureInfo.InvariantCulture, + $"{prefix[..Math.Min(prefix.Length, 30)]}_{_databaseRunId}_{counter:D5}" ); } diff --git a/Website/eleventy.config.js b/Website/eleventy.config.js index 0b34c349..00873d68 100644 --- a/Website/eleventy.config.js +++ b/Website/eleventy.config.js @@ -20,6 +20,11 @@ export default function(eleventyConfig) { const md = markdownIt(mdOptions).use(markdownItAnchor, mdAnchorOptions); eleventyConfig.setLibrary("md", md); + // Release notes carry untrusted PR titles/handles — render with raw HTML OFF. + // Implements [WEB-RELEASES]. + const releaseMd = markdownIt({ html: false, linkify: true, breaks: false }); + eleventyConfig.addFilter("releaseNotes", (body) => (body ? releaseMd.render(body) : "")); + eleventyConfig.addPlugin(syntaxHighlight); eleventyConfig.addPlugin(pluginRss); eleventyConfig.addPlugin(eleventyNavigationPlugin); diff --git a/Website/package-lock.json b/Website/package-lock.json index b0759c52..c0f4ea82 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -8,23 +8,25 @@ "name": "dataprovider-website", "version": "1.0.0", "dependencies": { - "js-yaml": "^4.1.0" + "entities": "^8.1.0", + "js-yaml": "^4.3.2" }, "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@playwright/test": "^1.47.0", "@types/node": "^25.6.0", "http-server": "^14.1.1", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" } }, "node_modules/@11ty/dependency-tree": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@11ty/dependency-tree/-/dependency-tree-4.0.1.tgz", - "integrity": "sha512-6EPI9ZkGU4BX2KNZpWlf4WdV3vrmIWQpn//nAXicTzdPubI3jZlmFdqEv0Yj5M7oavRUGNzw9GbV9cBxhulZWw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@11ty/dependency-tree/-/dependency-tree-4.0.2.tgz", + "integrity": "sha512-RTF6VTZHatYf7fSZBUN3RKwiUeJh5dhWV61gDPrHhQF2/gzruAkYz8yXuvGLx3w3ZBKreGrR+MfYpSVkdbdbLA==", "dev": true, "license": "MIT", "dependencies": { @@ -45,45 +47,45 @@ } }, "node_modules/@11ty/eleventy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.2.tgz", - "integrity": "sha512-IcsDlbXnBf8cHzbM1YBv3JcTyLB35EK88QexmVyFdVJVgUU6bh9g687rpxryJirHzo06PuwnYaEEdVZQfIgRGg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.6.tgz", + "integrity": "sha512-ZlSiR1PLdS2lv7TelBgWAhcvMiLNZkPBlLEb+lh7kGYZ+Mk0bo9qcYgVsewvw9W7Em0RH3wd01h5fAstNDh0zA==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/dependency-tree": "^4.0.0", - "@11ty/dependency-tree-esm": "^2.0.0", + "@11ty/dependency-tree": "^4.0.2", + "@11ty/dependency-tree-esm": "^2.0.4", "@11ty/eleventy-dev-server": "^2.0.8", - "@11ty/eleventy-plugin-bundle": "^3.0.6", + "@11ty/eleventy-plugin-bundle": "^3.0.7", "@11ty/eleventy-utils": "^2.0.7", "@11ty/lodash-custom": "^4.17.21", - "@11ty/posthtml-urls": "^1.0.1", - "@11ty/recursive-copy": "^4.0.2", + "@11ty/posthtml-urls": "^1.0.3", + "@11ty/recursive-copy": "^4.0.4", "@sindresorhus/slugify": "^2.2.1", "bcp-47-normalize": "^2.3.0", "chokidar": "^3.6.0", - "debug": "^4.4.1", + "debug": "^4.4.3", "dependency-graph": "^1.0.0", "entities": "^6.0.1", "filesize": "^10.1.6", "gray-matter": "^4.0.3", "iso-639-1": "^3.1.5", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "kleur": "^4.1.5", - "liquidjs": "^10.21.1", - "luxon": "^3.6.1", - "markdown-it": "^14.1.0", + "liquidjs": "^10.27.0", + "luxon": "^3.7.2", + "markdown-it": "^14.2.0", "minimist": "^1.2.8", - "moo": "^0.5.2", + "moo": "0.5.2", "node-retrieve-globals": "^6.0.1", "nunjucks": "^3.2.4", - "picomatch": "^4.0.2", + "picomatch": "^4.0.4", "please-upgrade-node": "^3.2.0", - "posthtml": "^0.16.6", + "posthtml": "^0.16.7", "posthtml-match-helper": "^2.0.3", - "semver": "^7.7.2", - "slugify": "^1.6.6", - "tinyglobby": "^0.2.14" + "semver": "^7.8.1", + "slugify": "^1.6.9", + "tinyglobby": "^0.2.16" }, "bin": { "eleventy": "cmd.cjs" @@ -128,29 +130,19 @@ } }, "node_modules/@11ty/eleventy-navigation": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-0.3.5.tgz", - "integrity": "sha512-4aKW5aIQDFed8xs1G1pWcEiFPcDSwZtA4IH1eERtoJ+Xy+/fsoe0pzbDmw84bHZ9ACny5jblENhfZhcCxklqQw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-1.0.5.tgz", + "integrity": "sha512-zb6xe29cM9viSdYtZywKIkJw2HIROyBINdBcFWC9uD0c/jYOTAex5nwy3HNEuh5t6/Ld/S9V4gEizfmeYuYpCQ==", "dev": true, "license": "MIT", "dependencies": { - "dependency-graph": "^0.11.0" + "dependency-graph": "^1.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/11ty" } }, - "node_modules/@11ty/eleventy-navigation/node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/@11ty/eleventy-plugin-bundle": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.7.tgz", @@ -171,16 +163,16 @@ } }, "node_modules/@11ty/eleventy-plugin-rss": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-2.0.4.tgz", - "integrity": "sha512-LF60sGVlxGTryQe3hTifuzrwF8R7XbrNsM2xfcDcNMSliLN4kmB+7zvoLRySRx0AQDjqhPTAeeeT0ra6/9zHUQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-3.0.0.tgz", + "integrity": "sha512-kKW4DcR57xAyRx0e8gNhKh56ahHVEaAj8/TuXQDnw+B46ig2bWADJAlyj/GdV37IG5ja9dZ4SgKZrs/CHz6YWQ==", "dev": true, "license": "MIT", "dependencies": { - "@11ty/eleventy-utils": "^2.0.0", - "@11ty/posthtml-urls": "^1.0.1", - "debug": "^4.4.0", - "posthtml": "^0.16.6" + "@11ty/eleventy-utils": "^2.0.7", + "@11ty/posthtml-urls": "^1.0.2", + "debug": "^4.4.3", + "posthtml": "^0.16.7" }, "funding": { "type": "opencollective", @@ -215,6 +207,19 @@ "url": "https://opencollective.com/11ty" } }, + "node_modules/@11ty/eleventy/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/@11ty/lodash-custom": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@11ty/lodash-custom/-/lodash-custom-4.17.21.tgz", @@ -230,9 +235,9 @@ } }, "node_modules/@11ty/posthtml-urls": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.2.tgz", - "integrity": "sha512-0vaV3Wt0surZ+oS1VdKKe0axeeupuM+l7W/Z866WFQwF+dGg2Tc/nmhk/5l74/Y55P8KyImnLN9CdygNw2huHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.3.tgz", + "integrity": "sha512-1YvhnkaNlFnnJic1rBMWmTC2adbuy+JQiBfl1Hecr1Wjjik1pQZmGyk/eC9zKX/FQv52s2Nht1Gi/UwhYqrBeg==", "dev": true, "license": "MIT", "dependencies": { @@ -246,15 +251,15 @@ } }, "node_modules/@11ty/recursive-copy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.3.tgz", - "integrity": "sha512-SX48BTLEGX8T/OsKWORsHAAeiDsbFl79Oa/0Wg/mv/d27b7trCVZs7fMHvpSgDvZz/fZqx5rDk8+nx5oyT7xBw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.4.tgz", + "integrity": "sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==", "dev": true, "license": "ISC", "dependencies": { "errno": "^1.0.0", "junk": "^3.1.0", - "maximatch": "^0.1.0", + "minimatch": "^3.1.5", "slash": "^3.0.0" }, "engines": { @@ -430,49 +435,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-differ": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", - "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -563,9 +525,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -846,13 +808,12 @@ } }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -1066,9 +1027,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -1213,9 +1174,9 @@ } }, "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -1536,9 +1497,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1578,19 +1549,29 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } }, "node_modules/liquidjs": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.24.0.tgz", - "integrity": "sha512-TAUNAdgwaAXjjcUFuYVJm9kOVH7zc0mTKxsG9t9Lu4qdWjB2BEblyVIYpjWcmJLMGgiYqnGNJjpNMHx0gp/46A==", + "version": "10.27.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.0.tgz", + "integrity": "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==", "dev": true, "license": "MIT", "dependencies": { @@ -1626,15 +1607,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -1677,22 +1668,6 @@ "node": ">= 0.4" } }, - "node_modules/maximatch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", - "integrity": "sha512-9ORVtDUFk4u/NFfo0vG/ND/z7UQCVZBL539YW0+U1I7H1BkZwizcPx5foFv7LCPcBnm2U6RjFnQOsIvN4/Vm2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-differ": "^1.0.0", - "array-union": "^1.0.1", - "arrify": "^1.0.0", - "minimatch": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -1741,9 +1716,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1906,9 +1881,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2070,9 +2045,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2164,9 +2139,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -2304,9 +2279,9 @@ } }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "dev": true, "license": "MIT", "engines": { @@ -2367,14 +2342,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/Website/package.json b/Website/package.json index daa32030..967a2cb9 100644 --- a/Website/package.json +++ b/Website/package.json @@ -12,16 +12,18 @@ "test:e2e:install": "playwright install chromium --with-deps" }, "devDependencies": { - "@11ty/eleventy": "^3.1.2", - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "@11ty/eleventy": "^3.1.6", + "@11ty/eleventy-navigation": "^1.0.5", + "@11ty/eleventy-plugin-rss": "^3.0.0", + "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@playwright/test": "^1.47.0", "@types/node": "^25.6.0", "http-server": "^14.1.1", + "markdown-it": "^14.2.0", "markdown-it-anchor": "^9.2.0" }, "dependencies": { - "js-yaml": "^4.1.0" + "entities": "^8.1.0", + "js-yaml": "^4.3.2" } } diff --git a/Website/scripts/copy-readmes.cjs b/Website/scripts/copy-readmes.cjs index 3b7c5d0b..d58508fb 100644 --- a/Website/scripts/copy-readmes.cjs +++ b/Website/scripts/copy-readmes.cjs @@ -136,23 +136,6 @@ function copyImages() { } } - // Copy images from subdirectories (relative to each README) - const subdirImages = [ - { src: 'Samples/image.png', dest: 'Samples/image.png' }, - { src: 'Samples/image-1.png', dest: 'Samples/image-1.png' } - ]; - for (const img of subdirImages) { - const src = path.join(REPO_ROOT, img.src); - const destDir = path.join(ASSETS_DIR, path.dirname(img.dest)); - const dest = path.join(ASSETS_DIR, img.dest); - if (fs.existsSync(src)) { - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - fs.copyFileSync(src, dest); - console.log(`Copied image: ${img.src}`); - } - } } function main() { diff --git a/Website/scripts/generate-api-docs.cjs b/Website/scripts/generate-api-docs.cjs index d79c9582..0a5b90ec 100644 --- a/Website/scripts/generate-api-docs.cjs +++ b/Website/scripts/generate-api-docs.cjs @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); +const { decodeHTML } = require('entities'); const DOCFX_API_DIR = path.join(__dirname, '../docfx/api'); const OUTPUT_DIR = path.join(__dirname, '../src/apidocs'); @@ -45,13 +46,8 @@ function formatExample(exampleArray) { code = codeMatch[1]; } - // Unescape HTML entities - code = code - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'"); + // Implements [WEB-DOCFX-EXAMPLES]: decode exactly one layer of entities. + code = decodeHTML(code); md += '```csharp\n' + code.trim() + '\n```\n\n'; } diff --git a/Website/scripts/test-api-docs.py b/Website/scripts/test-api-docs.py new file mode 100644 index 00000000..eca2ecb2 --- /dev/null +++ b/Website/scripts/test-api-docs.py @@ -0,0 +1,39 @@ +"""Run the real DocFX generator against nested entities. [WEB-DOCFX-EXAMPLES]""" +from pathlib import Path +import json +import os +import shutil +import subprocess +import tempfile + +WEBSITE = Path(__file__).resolve().parent.parent + + +def assert_example(root: Path, encoded: str, expected: str) -> None: + fixture = {"items": [{ + "uid": "Example.Sample", "name": "Example.Sample", "namespace": "Example", + "type": "Class", "example": [f"
{encoded}
"] + }, {"uid": "Example", "name": "Example", "type": "Namespace"}]} + (root / "docfx/api/example.yml").write_text(json.dumps(fixture)) + subprocess.run(["node", str(root / "scripts/generate-api-docs.cjs")], + check=True, capture_output=True, text=True, timeout=15, + env=os.environ | {"NODE_PATH": str(WEBSITE / "node_modules")}) + actual = (root / "src/apidocs/Example/Sample/index.md").read_text() + assert f"```csharp\n{expected}\n```" in actual, (encoded, expected, actual) + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="docfx-entities-") as folder: + root = Path(folder) + (root / "docfx/api").mkdir(parents=True) + (root / "scripts").mkdir() + shutil.copy(WEBSITE / "scripts/generate-api-docs.cjs", root / "scripts") + for encoded, expected in [ + ("&quot;", """), ("&#39;", "'"), + ("&lt;", "<"), (""<>'&", "\"<>'&"), + ]: + assert_example(root=root, encoded=encoded, expected=expected) + + +if __name__ == "__main__": + main() diff --git a/Website/src/_data/navigation.json b/Website/src/_data/navigation.json index 39f5c36e..4fc92e58 100644 --- a/Website/src/_data/navigation.json +++ b/Website/src/_data/navigation.json @@ -3,6 +3,7 @@ { "text": "Docs", "url": "/docs/" }, { "text": "API", "url": "/apidocs/" }, { "text": "Blog", "url": "/blog/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "GitHub", "url": "https://github.com/Nimblesite/DataProvider", "external": true } ], "docs": [ @@ -22,7 +23,7 @@ { "text": "LQL", "url": "/docs/lql/" }, { "text": "Sync", "url": "/docs/sync/" }, { "text": "Migrations", "url": "/docs/migrations/" }, - { "text": "Clinical Coding Platform", "url": "/docs/samples/" } + { "text": "Clinical Coding Platform", "url": "https://github.com/Nimblesite/ClinicalCoding", "external": true } ] } ], @@ -43,7 +44,7 @@ { "text": "Getting Started", "url": "/docs/getting-started/" }, { "text": "Installation", "url": "/docs/installation/" }, { "text": "LQL", "url": "/docs/lql/" }, - { "text": "Clinical Coding Platform", "url": "/docs/samples/" } + { "text": "Clinical Coding Platform", "url": "https://github.com/Nimblesite/ClinicalCoding", "external": true } ] }, { @@ -58,6 +59,7 @@ "title": "More", "items": [ { "text": "Blog", "url": "/blog/" }, + { "text": "Releases", "url": "/releases/" }, { "text": "About", "url": "/about/" } ] } diff --git a/Website/src/_data/release.js b/Website/src/_data/release.js new file mode 100644 index 00000000..024e6caa --- /dev/null +++ b/Website/src/_data/release.js @@ -0,0 +1,102 @@ +// agent-pmo:795a9c2 +// Build-time GitHub Releases data file. Implements [WEB-RELEASES]. +// +// Eleventy runs this during the static build and bakes the result into the +// /releases/ page — the browser makes ZERO network calls. Notes are rendered +// by the `releaseNotes` filter in eleventy.config.js (markdown-it, html:false), +// because release bodies carry untrusted PR titles/handles. + +const REPO = "Nimblesite/DataProvider"; +const MAX_RELEASES = 30; +const RECENT_COUNT = 4; +const API_URL = `https://api.github.com/repos/${REPO}/releases?per_page=${MAX_RELEASES}`; +const RELEASES_URL = `https://github.com/${REPO}/releases`; + +function fallback(reason) { + if (reason) console.warn(`[_data/release] using fallback — ${reason}`); + return { + available: false, + tag: null, + version: null, + url: RELEASES_URL, + releasesUrl: RELEASES_URL, + publishedAt: null, + publishedDate: null, + items: [], + recent: [], + }; +} + +function versionFromTag(tag) { + if (!tag) return null; + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +function formatDate(value) { + if (!value) return null; + return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" }).format( + new Date(value), + ); +} + +function mapRelease(data) { + const tag = data.tag_name; + const publishedAt = data.published_at || null; + return { + tag, + version: versionFromTag(tag), + title: data.name || tag, + url: data.html_url || RELEASES_URL, + publishedAt, + publishedDate: formatDate(publishedAt), + prerelease: Boolean(data.prerelease), + body: data.body || "", + }; +} + +export default async function () { + if (process.env.SKIP_RELEASE_FETCH === "1") { + return fallback("SKIP_RELEASE_FETCH=1"); + } + + const headers = { + "User-Agent": "DataProvider-website-build", + Accept: "application/vnd.github+json", + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 8000); + const response = await fetch(API_URL, { headers, signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + return fallback(`GitHub API responded ${response.status}`); + } + + const data = await response.json(); + const items = data.filter((item) => !item.draft).map(mapRelease); + const latest = items.find((item) => !item.prerelease) || items[0]; + if (!latest) { + return fallback("response missing releases"); + } + + return { + available: true, + tag: latest.tag, + version: latest.version, + url: latest.url, + releasesUrl: RELEASES_URL, + publishedAt: latest.publishedAt, + publishedDate: latest.publishedDate, + items, + recent: items.slice(0, RECENT_COUNT), + }; + } catch (err) { + return fallback(err.message); + } +} diff --git a/Website/src/_includes/layouts/base.njk b/Website/src/_includes/layouts/base.njk index 709e4992..e25f1738 100644 --- a/Website/src/_includes/layouts/base.njk +++ b/Website/src/_includes/layouts/base.njk @@ -113,7 +113,7 @@ diff --git a/Website/src/about.md b/Website/src/about.md index 8712340e..3c4dc8f1 100644 --- a/Website/src/about.md +++ b/Website/src/about.md @@ -27,7 +27,7 @@ description: "DataProvider is an open-source .NET toolkit for type-safe database
  • LQL - Lambda Query Language: write once, transpile to any SQL dialect. Ships with a native Rust LSP and a VS Code extension
  • Migrations - YAML schemas applied via the DataProviderMigrate CLI tool
  • Sync - Offline-first bidirectional synchronisation with conflict resolution
  • -
  • Clinical Coding Platform - Reference implementation: FHIR R5 microservices, ICD-10 semantic search with pgvector, and a React dashboard (GitHub)
  • +
  • Clinical Coding Platform - Reference implementation: FHIR R5 services, bidirectional sync, and a React dashboard (GitHub)
  • Get Involved

    diff --git a/Website/src/assets/images/cc.mp4 b/Website/src/assets/images/cc.mp4 deleted file mode 100644 index 84742cf0..00000000 Binary files a/Website/src/assets/images/cc.mp4 and /dev/null differ diff --git a/Website/src/assets/images/clinical-coding/login.png b/Website/src/assets/images/clinical-coding/login.png deleted file mode 100644 index 38b64fd9..00000000 Binary files a/Website/src/assets/images/clinical-coding/login.png and /dev/null differ diff --git a/Website/src/blog/getting-started-dataprovider.md b/Website/src/blog/getting-started-dataprovider.md index 88b142b4..ac6a4980 100644 --- a/Website/src/blog/getting-started-dataprovider.md +++ b/Website/src/blog/getting-started-dataprovider.md @@ -61,4 +61,4 @@ The `DataProvider` CLI validates every query against your schema at build time a - [Installation](/docs/installation/) — the full package reference - [Getting Started](/docs/getting-started/) — end-to-end walkthrough -- [Clinical Coding Platform](/docs/samples/) — reference implementation +- [Clinical Coding Platform](https://github.com/Nimblesite/ClinicalCoding) — reference implementation diff --git a/Website/src/blog/lql-simplifies-development.md b/Website/src/blog/lql-simplifies-development.md index fee13d97..7692406a 100644 --- a/Website/src/blog/lql-simplifies-development.md +++ b/Website/src/blog/lql-simplifies-development.md @@ -69,4 +69,4 @@ The transpiler supports the full set of pipeline operators — `filter`, `select - [LQL documentation](/docs/lql/) - [Installation guide](/docs/installation/) -- [Clinical Coding Platform](/docs/samples/) — LQL in production-style use +- [Clinical Coding Platform](https://github.com/Nimblesite/ClinicalCoding) — LQL in production-style use diff --git a/Website/src/docs/getting-started.md b/Website/src/docs/getting-started.md index 42236798..c190a928 100644 --- a/Website/src/docs/getting-started.md +++ b/Website/src/docs/getting-started.md @@ -184,5 +184,5 @@ This is the **default** generator output — errors are explicit in the return t - [Quick Start](/docs/quick-start/) — shorter example focused on the generated API - [Installation](/docs/installation/) — full package reference -- [Clinical Coding Platform](/docs/samples/) — see every tool used in a real multi-service reference implementation +- [Clinical Coding Platform](https://github.com/Nimblesite/ClinicalCoding) — see every tool used in a real multi-service reference implementation - [LQL](/docs/lql/) — the Lambda Query Language in depth diff --git a/Website/src/docs/installation.md b/Website/src/docs/installation.md index 365a847a..3c6e7c7a 100644 --- a/Website/src/docs/installation.md +++ b/Website/src/docs/installation.md @@ -99,4 +99,4 @@ Add the following to your `.csproj`: - [Getting Started](/docs/getting-started/) — end-to-end walkthrough using all three CLI tools - [Quick Start](/docs/quick-start/) — 5-minute generated-query example -- [Clinical Coding Platform](/docs/samples/) — the full reference implementation using every package above +- [Clinical Coding Platform](https://github.com/Nimblesite/ClinicalCoding) — the full reference implementation using every package above diff --git a/Website/src/docs/quick-start.md b/Website/src/docs/quick-start.md index 94ee5c4d..e347b291 100644 --- a/Website/src/docs/quick-start.md +++ b/Website/src/docs/quick-start.md @@ -110,4 +110,4 @@ var sql = sqlResult switch - [Getting Started](/docs/getting-started/) — full end-to-end walkthrough - [DataProvider](/docs/dataprovider/) — source generation reference - [LQL](/docs/lql/) — the Lambda Query Language -- [Clinical Coding Platform](/docs/samples/) — reference implementation +- [Clinical Coding Platform](https://github.com/Nimblesite/ClinicalCoding) — reference implementation diff --git a/Website/src/docs/samples.md b/Website/src/docs/samples.md deleted file mode 100644 index 7e2b6ae6..00000000 --- a/Website/src/docs/samples.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -layout: layouts/docs.njk -title: Nimblesite Clinical Coding Platform — Reference Implementation -description: A full-stack healthcare reference implementation for the DataProvider toolkit. Four .NET 10 microservices, FHIR R5, ICD-10 semantic search with pgvector, bidirectional sync, and WebAuthn auth. ---- - -The **Nimblesite Clinical Coding Platform** is the official reference implementation for the DataProvider toolkit — a realistic multi-service healthcare application that exercises every package and CLI tool working together. - -> **Technology demonstration only — not for production use.** No warranty, no clinical certification. - - - -- **Repository**: [github.com/Nimblesite/ClinicalCoding](https://github.com/Nimblesite/ClinicalCoding) -- **License**: MIT © 2026 Nimblesite Pty Ltd -- **Stack**: .NET 10, PostgreSQL + pgvector, TypeScript + React, Docker Compose - -## What it demonstrates - -- FHIR R5 Patient, Encounter, Condition, MedicationRequest, Practitioner, Appointment -- Agentic ICD-10 coding via pgvector semantic search over 16,000+ codes -- Bidirectional sync between Clinical and Scheduling domains -- WebAuthn passkeys + record-level RBAC via Gatekeeper -- Build-time code generation from all three CLI tools (`DataProviderMigrate`, `Lql`, `DataProvider`) -- 100% `Result` — no thrown exceptions on the query path - -## Get started - -Full setup instructions, architecture diagrams, API reference, and contribution guidelines are in the repository. - -```bash -git clone https://github.com/Nimblesite/ClinicalCoding.git -cd ClinicalCoding -make start-docker -``` - -Open **http://localhost:5173** — prerequisites: Docker, .NET 10 SDK, GNU Make. - -## Next Steps - -- [Installation](/docs/installation/) — install the CLI tools the platform uses -- [Getting Started](/docs/getting-started/) — build a smaller version of this stack -- [DataProvider](/docs/dataprovider/) — the source generator in detail -- [LQL](/docs/lql/) — the query language -- [Sync](/docs/sync/) — the bidirectional sync framework -- [Gatekeeper](/docs/gatekeeper/) — WebAuthn + RBAC diff --git a/Website/src/index.njk b/Website/src/index.njk index 5e17e455..1090eadd 100644 --- a/Website/src/index.njk +++ b/Website/src/index.njk @@ -57,8 +57,8 @@ description: "A complete .NET 10 toolkit for compile-time safe database access:
    CC

    Clinical Coding Platform

    -

    The full reference implementation: four .NET 10 microservices, FHIR R5, ICD-10 semantic search with pgvector, bidirectional sync, and WebAuthn auth.

    - Explore the reference → +

    The full reference implementation: four .NET 10 microservices, FHIR R5, bidirectional sync, and WebAuthn auth.

    + View the separate repository →
    @@ -103,8 +103,8 @@ description: "A complete .NET 10 toolkit for compile-time safe database access:
    CC

    Clinical Coding Platform

    -

    Reference implementation built on the full toolkit. FHIR R5, ICD-10 semantic search, bidirectional sync, React dashboard. GitHub.

    - Documentation → +

    Reference implementation built on the full toolkit. FHIR R5, bidirectional sync, and a React dashboard. GitHub.

    + Documentation →
    diff --git a/Website/src/releases.njk b/Website/src/releases.njk new file mode 100644 index 00000000..096b02d0 --- /dev/null +++ b/Website/src/releases.njk @@ -0,0 +1,40 @@ +--- +layout: layouts/base.njk +title: "Releases — DataProvider" +description: "Every DataProvider release with full release notes, generated at build time from the GitHub Releases for DataProvider." +permalink: /releases/ +--- + +{# + agent-pmo:795a9c2 + /releases/ page. Implements [WEB-RELEASES]. + Renders the build-time `release` data (src/_data/release.js). + Notes are rendered with the `releaseNotes` filter (markdown-it, html:false). +#} + +
    +
    +

    Releases

    +

    Every DataProvider release with full notes — generated at build time from the GitHub Releases.

    +

    View all releases on GitHub ↗

    + + {% if release.available and release.items.length %} + {% for item in release.items %} +
    +

    {{ item.title }} {{ item.tag }}

    +

    + {% if item.publishedDate %}{% endif %} + {% if item.prerelease %}· Prerelease{% endif %} +

    + {% if item.body %} + {{ item.body | releaseNotes | safe }} + {% else %} +

    Read the notes on GitHub

    + {% endif %} +
    + {% endfor %} + {% else %} +

    Releases will appear here once the first version ships. View all releases on GitHub

    + {% endif %} +
    +
    diff --git a/Website/tests/e2e/docs.spec.ts b/Website/tests/e2e/docs.spec.ts index 04a686d6..5ffe2385 100644 --- a/Website/tests/e2e/docs.spec.ts +++ b/Website/tests/e2e/docs.spec.ts @@ -17,7 +17,6 @@ const FORBIDDEN_STRINGS = [ "dotnet add package Lql.Postgres", "dotnet add package Lql.SqlServer", "dotnet add package Lql.TypeProvider.FSharp", - "HealthcareSamples", "Healthcare Samples", "--version 0.4.0", "version\": \"0.4.0", @@ -30,7 +29,6 @@ const DOC_PAGES = [ "/docs/installation/", "/docs/getting-started/", "/docs/quick-start/", - "/docs/samples/", "/docs/dataprovider/", "/docs/lql/", "/docs/sync/", @@ -71,8 +69,8 @@ test.describe("Homepage", () => { await expect(page.locator("h1")).toContainText("Effortless .NET Data Access"); - const samplesLink = page.locator('a[href="/docs/samples/"]').first(); - await expect(samplesLink).toBeVisible(); + const referenceLink = page.locator('a[href="https://github.com/Nimblesite/ClinicalCoding"]').first(); + await expect(referenceLink).toBeVisible(); const body = await getBodyText(page); for (const forbidden of FORBIDDEN_STRINGS) { @@ -135,39 +133,26 @@ test.describe("Quick Start page", () => { }); }); -test.describe("Clinical Coding Platform page", () => { - test("covers FHIR, ICD-10, pgvector, and links to GitHub", async ({ page }) => { - const response = await page.goto("/docs/samples/"); - expect(response?.status()).toBe(200); - - const title = await page.title(); - expect(title).toContain("Clinical Coding"); - - const body = await getBodyText(page); - expect(body).toContain("FHIR"); - expect(body).toContain("ICD-10"); - expect(body).toContain("pgvector"); - expect(body).toContain("DataProviderMigrate"); - - const repoLink = page.locator('a[href*="github.com/Nimblesite/ClinicalCoding"]').first(); - await expect(repoLink).toBeVisible(); - - const screenshot = page.locator('img[src*="clinical-coding/login"]').first(); - await expect(screenshot).toBeVisible(); - const src = await screenshot.getAttribute("src"); - expect(src).toBeTruthy(); - const imgResponse = await page.request.get(src!); - expect(imgResponse.status()).toBe(200); - }); - - test("architecture diagram renders as Mermaid SVG, not ASCII", async ({ page }) => { - await page.goto("/docs/samples/"); - - const mermaidDiv = page.locator("div.mermaid").first(); - await expect(mermaidDiv).toBeVisible(); +test.describe("Clinical Coding external repository", () => { + const repository = "https://github.com/Nimblesite/ClinicalCoding"; + + for (const route of ["/", "/about/", "/docs/getting-started/", "/docs/quick-start/", "/docs/installation/"]) { + test(`${route} links directly to the separate repository`, async ({ page }) => { + const response = await page.goto(route); + expect(response?.status()).toBe(200); + const links = page.locator(`a[href="${repository}"]`); + expect(await links.count()).toBeGreaterThan(0); + await expect(links.first()).toBeVisible(); + expect(await links.first().getAttribute("href")).toBe(repository); + }); + } - const svg = mermaidDiv.locator("svg").first(); - await expect(svg).toBeVisible({ timeout: 10_000 }); + test("the local sample files are absent", async () => { + const repoRoot = path.resolve(__dirname, "../../.."); + expect(fs.existsSync(path.join(repoRoot, "Samples"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "Website/src/docs/samples.md"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "Website/src/assets/images/clinical-coding/login.png"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "Website/src/assets/images/cc.mp4"))).toBe(false); }); }); @@ -181,7 +166,6 @@ test.describe("No ASCII box-drawing diagrams remain", () => { "/docs/installation/", "/docs/getting-started/", "/docs/quick-start/", - "/docs/samples/", "/docs/dataprovider/", "/docs/lql/", "/docs/sync/", @@ -217,7 +201,6 @@ test.describe("Version is centralised, not hardcoded", () => { "Website/src/docs/installation.md", "Website/src/docs/getting-started.md", "Website/src/docs/quick-start.md", - "Website/src/docs/samples.md", "Website/src/index.njk", "Website/src/blog/getting-started-dataprovider.md", "Website/src/blog/connecting-sql-server.md", @@ -273,7 +256,6 @@ test.describe("No Result marketing lead-ins", () => { "/docs/installation/", "/docs/getting-started/", "/docs/quick-start/", - "/docs/samples/", "/blog/getting-started-dataprovider/", "/blog/connecting-sql-server/", "/blog/lql-simplifies-development/", diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 72893673..5a67634f 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -2,43 +2,43 @@ "default_threshold": 90, "projects": { "DataProvider/Nimblesite.DataProvider.Core": { - "threshold": 89, + "threshold": 90, "include": "[Nimblesite.DataProvider.Core]*,[Nimblesite.Sql.Model]*,[Nimblesite.Lql.SQLite]*" }, "DataProvider/Nimblesite.DataProvider.Example": { - "threshold": 36, + "threshold": 37, "include": "[Nimblesite.DataProvider.Core]*,[Nimblesite.DataProvider.Example]*,[Nimblesite.Lql.SQLite]*,[Nimblesite.Sql.Model]*" }, "Lql/Nimblesite.Lql.Core": { - "threshold": 71, + "threshold": 74, "include": "[Nimblesite.Lql.Core]*,[Nimblesite.Lql.Postgres]*,[Nimblesite.Lql.SqlServer]*,[Nimblesite.Lql.SQLite]*" }, "Lql/Nimblesite.Lql.TypeProvider.FSharp": { - "threshold": 39, + "threshold": 47, "include": "[Nimblesite.Lql.TypeProvider.FSharp]*,[Nimblesite.Lql.Core]*,[Nimblesite.Lql.SQLite]*,[Nimblesite.Sql.Model]*" }, "Migration/Nimblesite.DataProvider.Migration.Core": { - "threshold": 86, + "threshold": 87, "include": "[Nimblesite.DataProvider.Migration.Core]*,[Nimblesite.DataProvider.Migration.SQLite]*,[Nimblesite.DataProvider.Migration.Postgres]*" }, "Sync/Nimblesite.Sync.Core": { - "threshold": 64, + "threshold": 65, "include": "[Nimblesite.Sync.Core]*" }, "Sync/Nimblesite.Sync.SQLite": { - "threshold": 34, + "threshold": 40, "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.SQLite]*" }, "Sync/Nimblesite.Sync.Postgres": { - "threshold": 24, + "threshold": 26, "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.Postgres]*,[Nimblesite.Sync.SQLite]*" }, "Sync/Nimblesite.Sync.Integration": { - "threshold": 9, + "threshold": 10, "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.SQLite]*,[Nimblesite.Sync.Postgres]*,[Nimblesite.Sync.Http]*" }, "Sync/Nimblesite.Sync.Http": { - "threshold": 33, + "threshold": 36, "include": "[Nimblesite.Sync.Core]*,[Nimblesite.Sync.SQLite]*,[Nimblesite.Sync.Postgres]*,[Nimblesite.Sync.Http]*" }, "Reporting/Nimblesite.Reporting": { @@ -46,14 +46,14 @@ "include": "[Nimblesite.Reporting.Engine]*" }, "Reporting/Nimblesite.Reporting.Integration": { - "threshold": 40, + "threshold": 76, "include": "[Nimblesite.Reporting.Engine]*,[Nimblesite.Reporting.Api]*" }, "Lql/lql-lsp-rust": { - "threshold": 95 + "threshold": 98 }, "Lql/LqlExtension": { - "threshold": 44 + "threshold": 54 } } } diff --git a/coverlet.runsettings b/coverlet.runsettings index e432e2ab..f60223ac 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -1,5 +1,10 @@ + + + + true + diff --git a/docs/plans/codegen-tool-consolidation.md b/docs/plans/codegen-tool-consolidation.md index aa0cc29b..f6c793d5 100644 --- a/docs/plans/codegen-tool-consolidation.md +++ b/docs/plans/codegen-tool-consolidation.md @@ -87,7 +87,7 @@ Per [PKG-LAYOUT] the tool exe lives under `build/tool/net10.0/`, NOT in a `tools | Path | When | |---|---| -| `Lql/Nimblesite.Lql.Cli.Postgres/` | After consumers (HealthcareSamples, ai_cms) cut over to the unified `DataProvider` tool — the tool's internal LQL pipeline supersedes it. | +| `Lql/Nimblesite.Lql.Cli.Postgres/` | After consumers ([ClinicalCoding](https://github.com/Nimblesite/ClinicalCoding), ai_cms) cut over to the unified `DataProvider` tool — the tool's internal LQL pipeline supersedes it. | | `Lql/Nimblesite.Lql.Cli.SQLite/` | Same. | The LQL CLI test suites (`Nimblesite.Lql.Cli.SQLite.Tests`, etc.) stay alive as behaviour-equivalence harnesses pointed at `Nimblesite.Lql.Core` per [DEPS-EXTRACT], not at the obsolete CLIs. @@ -161,7 +161,7 @@ That is the entire consumer-side delta. No tool manifest. No ``. No `Gen - MSBuild end-to-end test consuming the packed `DataProvider.{version}.nupkg` from a local feed. - RID matrix on `win-x64`, `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64`. 9. **Update [`RELEASE-PLAN.md`](./RELEASE-PLAN.md)** + the release workflow to pack `DataProvider` from `DataProvider/DataProvider/DataProvider.csproj`. -10. **Cut over consumers** (HealthcareSamples, ai_cms) in their own PRs after the new tool ships to nuget.org. Not in scope for this plan. +10. **Cut over consumers** (ClinicalCoding, ai_cms) in their own PRs after the new tool ships to nuget.org. Not in scope for this plan. ## Risks @@ -276,5 +276,5 @@ That is the entire consumer-side delta. No tool manifest. No ``. No `Gen - [ ] **OBSOLETE** — Mark `Lql/Nimblesite.Lql.Cli.Postgres` + `Lql/Nimblesite.Lql.Cli.SQLite` `Program` types `[Obsolete]` once the unified tool's LQL pipeline lands. - [ ] **RELEASE** — Update [`RELEASE-PLAN.md`](./RELEASE-PLAN.md): add `DataProvider` (bare name) to the CLI tools table. - [ ] **RELEASE** — Update `.github/workflows/release.yml`: add `dotnet pack DataProvider/DataProvider/DataProvider.csproj` and upload the resulting nupkg. -- [ ] **CONSUMER** — (Not in this repo) Cut HealthcareSamples + ai_cms over to `PackageReference Include="DataProvider"` in separate PRs after first release. +- [ ] **CONSUMER** — (Not in this repo) Cut ClinicalCoding + ai_cms over to `PackageReference Include="DataProvider"` in separate PRs after first release. - [ ] **CLEANUP** — After one release cycle of overlap, delete the legacy `Lql/Nimblesite.Lql.Cli.Postgres` + `Lql/Nimblesite.Lql.Cli.SQLite` projects entirely. diff --git a/docs/plans/dataprovider-migrate-aot.md b/docs/plans/dataprovider-migrate-aot.md new file mode 100644 index 00000000..eb4af1ea --- /dev/null +++ b/docs/plans/dataprovider-migrate-aot.md @@ -0,0 +1,122 @@ +# Plan: AOT-compile and AOT-test DataProviderMigrate + +Spec group: `[MIG-AOT]`. Make the `DataProviderMigrate` CLI publish as a native +AOT executable and prove it works via tests that run the **native binary** (not +in-process `Program.Main`). + +## Why this is non-trivial + +The CLI's csproj lists only three project references, but the real AOT surface is +the whole transitive graph: + +``` +DataProviderMigrate +└─ Migration.Core ─┬─ YamlDotNet (reflection serialization) ← BLOCKER 1 + ├─ Nimblesite.Lql.Core ─ Nimblesite.Sql.Model ← BLOCKER 2 + ├─ Nimblesite.Lql.Postgres / SQLite / SqlServer + └─ (SqlParserCS, Outcome) + Migration.SQLite ─ Microsoft.Data.Sqlite (native SQLite, AOT-friendly) + Migration.Postgres ─ Npgsql (verify trim/AOT cleanliness) +``` + +## Blockers (empirically confirmed via `dotnet publish -p:PublishAot=true -r osx-arm64`) + +### [MIG-AOT-YAML] YamlDotNet runtime reflection — BLOCKER 1 +`SchemaYamlSerializer` uses `SerializerBuilder`/`DeserializerBuilder`, which reflect +over `SchemaDefinition` at runtime. AOT-incompatible. +- Fix: migrate to YamlDotNet 16.x **static** API — `[YamlStaticContext]` + source + generator + `StaticSerializerBuilder`/`StaticDeserializerBuilder`. +- **OUTCOME: static generator REJECTED.** `Vecc.YamlDotNet.Analyzers.StaticGenerator` + 16.2.1 emits a deserializer that does property *assignment* (`obj.X = v`), which + fails `CS8852` against our immutable `init`-only records — every schema property. + Immutable records are a hard project rule, so the generated path is unusable. +- **DECISION: hand-rolled, reflection-free YAML.** Verified AOT-clean via a probe: + YamlDotNet's `RepresentationModel` DOM (read) and low-level `Emitter` + `Events` + (write) publish with **zero IL warnings** and run natively. The schema format is + already fully hand-encoded by the four converters, so a purpose-built + writer/reader preserves it exactly with no new dependency. New files: + `SchemaYamlWriter.cs` (Emitter), `SchemaYamlReader.cs` (DOM), shared scalar + encode/parse helpers; `SchemaYamlSerializer` keeps its public API. + +### [MIG-AOT-DYNCODE] Expression.Lambda().Compile() — BLOCKER 2 +`IL3050` from `Nimblesite.Sql.Model`: +- `SelectStatementVisitor.cs:470` — `Expression.Lambda(expr).Compile().DynamicInvoke()` +- `SelectStatementLinqExtensions.cs:426` — same pattern +These are constant-folding fallbacks on a narrow LINQ-translation path the migrate +CLI does not exercise. `PredicateBuilder`'s `Expression.Lambda>` only +*builds* trees (no `.Compile()`) — AOT-safe, leave alone. +- **DONE.** Replaced both `.Compile().DynamicInvoke()` sites with a new + `ConstantExpressionEvaluator.TryEvaluate` that walks the expression tree + (constants, captured fields, static members) with no dynamic code. The 63 + `SqlModelCoverageTests` still pass — behavior preserved. + +### [MIG-AOT-JSON] System.Text.Json reflection — BLOCKER (found during publish) +`SchemaSerializer.ToJson/FromJson` used `JsonSerializer.Serialize(T, options)` +(`IL2026` + `IL3050`). The CLI's migrate path never calls it, but it is public API. +- **DONE.** Added a source-generated `SchemaJsonContext` (`[JsonSerializable]`) and + routed through the typed `JsonTypeInfo` overload; the `PortableType` converter is + attached to the context options. Round-trip test passes. + +### [MIG-AOT-EXPORT] export command uses Assembly.LoadFrom — BLOCKER 3 +`Program.ExecuteExport` does `Assembly.LoadFrom` + `GetType` + reflected +property/method invoke. **Fundamentally incompatible** with a self-contained native +binary (cannot load arbitrary external managed DLLs). Decision required: +- Option A (recommended): keep `export` only in the non-AOT (dotnet-tool) build; + compile it out under an `AOT` MSBuild constant so the native binary ships + `migrate` only. The `export` tool remains available as the managed `dotnet tool`. +- Option B: drop `export` from the CLI entirely. +Default to **A** unless the user says otherwise. + +### [MIG-AOT-NPGSQL] Npgsql trim/AOT cleanliness +Npgsql 9 is largely AOT-friendly but may emit trim warnings for type mapping. +Capture and resolve any `IL2xxx`/`IL3xxx` from Npgsql; add a trim feature switch or +runtime directives only if needed. `Microsoft.Data.Sqlite` (native SQLitePCLRaw) is +AOT-friendly. + +## Distribution goal (from the user) + +Ship the migration tool BOTH ways from one source tree: +1. **Managed `dotnet tool`** via NuGet — the existing pack path (`PackAsTool`). + Keeps `export` (reflection is fine in the managed tool). +2. **Native binaries** via **Homebrew tap + Scoop bucket** — per-platform AOT + builds (`migrate` only). This is the `Shipwright` release model already used in + this repo (`shipwright.json`, brew/scoop publish workflows). The `[MIG-AOT-CI]` + native publish feeds those artifacts. + +## Build & test strategy + +- `DataProviderMigrate.csproj`: AOT analyzers always on (`IsAotCompatible`, + `EnableTrimAnalyzer`, `EnableAotAnalyzer`) so `make lint` catches reflection + regressions; `PublishAot`/`InvariantGlobalization`/`AOT` constant turn on only + under a Native AOT publish, leaving the managed `dotnet tool` pack untouched. +- Tests: existing CLI tests call `Program.Main` in-process — they do NOT test AOT. + `NativeAotMigrateSmokeTests` drives the PUBLISHED binary as a **subprocess** + (`ProcessStartInfo`), gated on `DATAPROVIDERMIGRATE_AOT_BIN` (set by the AOT + publish/CI), asserting exit codes + schema integrity + idempotency + the + export-unsupported message. `SkippableFact` skips visibly when the binary is + absent, so `make test` stays fast and CI runs the real native verification. +- `make` target + CI matrix job publishes native per-platform, sets the env var, + runs the smoke tests, then hands the binaries to brew/scoop publishing. + +## TODO + +- [x] **[MIG-AOT-DYNCODE]** `ConstantExpressionEvaluator` replaces both + `.Compile().DynamicInvoke()` sites. 63 SqlModel tests pass; IL3050 gone. +- [x] **[MIG-AOT-JSON]** Source-gen `SchemaJsonContext` + typed `JsonTypeInfo` + overloads. Round-trip test passes. +- [x] **[MIG-AOT-EXPORT]** `export` gated under `#if !AOT` in `Program.Export.cs`; + native build prints unsupported + exits non-zero. Managed tool keeps export. +- [ ] **[MIG-AOT-YAML]** Hand-rolled reflection-free writer (Emitter) + reader (DOM) + replacing the YamlDotNet reflection serializer (static gen rejected — CS8852 on + init-only records). Keep `SchemaYamlSerializer` public API; preserve format so + all YAML tests pass. (workflow `aot-yaml-mapper` in flight) +- [ ] **[MIG-AOT-NPGSQL]** Publish again; resolve any Npgsql/SqlParserCS/Outcome/LQL + (ANTLR) trim or AOT warnings. Clean `dotnet publish -p:PublishAot=true`. +- [x] **[MIG-AOT-CSPROJ]** AOT props + analyzers added; `AOT` constant + Invariant + + pack-off only under PublishAot. (verify managed pack still works post-YAML) +- [x] **[MIG-AOT-TEST]** `NativeAotMigrateSmokeTests` subprocess fixture + + `Xunit.SkippableFact`. (runs once Core compiles + binary published) +- [ ] **[MIG-AOT-CI]** `make` target + CI matrix publish native + run smoke test per + platform; feed binaries to brew tap + scoop bucket (Shipwright). Timeout = fail. +- [ ] **[MIG-AOT-VERIFY]** Final: clean AOT publish (osx-arm64) zero IL warnings, + native `migrate` smoke test passes end to end; managed `dotnet pack` still works. diff --git a/docs/plans/vector-support.md b/docs/plans/vector-support.md index 71f035ab..c6eb4362 100644 --- a/docs/plans/vector-support.md +++ b/docs/plans/vector-support.md @@ -6,7 +6,7 @@ ## Why -Consumers doing semantic search (HealthcareSamples ICD10 / MedEmbed-Small embeddings, any RAG app) need a single `float[]` C# type driving the same YAML column across Postgres production, on-device SQLite replicas, and SQL Server enterprise. Storing as `text` + query-time casts is unacceptable. +Applications using semantic search need a single `float[]` C# type driving the same YAML column across Postgres production, on-device SQLite replicas, and SQL Server enterprise. Storing as `text` + query-time casts is unacceptable. ## Backends & hosting @@ -97,7 +97,7 @@ Consumers doing semantic search (HealthcareSamples ICD10 / MedEmbed-Small embedd 9. **`dataprovider-ci-prep`**: failing tests first (CLAUDE.md process), then implementation validation, then `make ci`. Bump `PostgresContainerFixture` to `pgvector/pgvector:pg16`. 10. **`dataprovider-ci-prep`** (last): version bump to `0.9.0-beta` across every csproj + `Directory.Build.props`. Final `make ci` run. 11. **Owner**: `git tag v0.9.0-beta` → `release.yml` triggers and publishes all packages. -12. **HealthcareSamples**: pin to 0.9.0-beta, convert `text` embedding columns to `Vector(384)`, rewrite their LQL to use `cosine_distance` (pending 0.10.0-beta — in the meantime they emit raw similarity SQL via LQL passthrough). +12. **[ClinicalCoding](https://github.com/Nimblesite/ClinicalCoding)**: pin to 0.9.0-beta, convert `text` embedding columns to `Vector(384)`, rewrite their LQL to use `cosine_distance` (pending 0.10.0-beta — in the meantime they emit raw similarity SQL via LQL passthrough). ## File inventory (exact, by owner) @@ -148,6 +148,6 @@ If the cross-backend vector work cannot go green in 0.9.0-beta: 1. `git revert` the vector commits. 2. Ship 0.9.0-beta as a pure `Lql`-rename + BUG-fixes release (no vector). 3. Push vector to 0.10.0-beta. -4. HealthcareSamples continues with text+cast until 0.10.0-beta. +4. ClinicalCoding continues with text+cast until 0.10.0-beta. **No partial vector rollout.** Either all three backends work or none ship. A half-vector 0.9.0-beta that works on Postgres but not SQLite violates the owner rule. diff --git a/docs/plans/website-update.md b/docs/plans/website-update.md deleted file mode 100644 index fc3e72e4..00000000 --- a/docs/plans/website-update.md +++ /dev/null @@ -1,257 +0,0 @@ -# Website Audit & Fix — Comprehensive Update - -## Context - -DataProvider has been through a major overhaul. All packages are now published to NuGet under two naming conventions: - -- **Top-level CLI tools / flagship packages**: `DataProvider` (v0.9.6-beta), `DataProviderMigrate` (v0.9.6-beta), `Lql` (v0.9.6-beta) -- **Library packages**: `Nimblesite.DataProvider.*`, `Nimblesite.Lql.*`, `Nimblesite.Sync.*`, `Nimblesite.Reporting.Engine`, `Nimblesite.Sql.Model` — all at v0.9.6-beta - -The current website (`Website/src/docs/`) still advertises stale package names: -- `DataProvider.MySql` / `DataProvider.Sqlite` / `DataProvider.SqlServer` / `DataProvider.Postgres` (do not exist; real names are `Nimblesite.DataProvider.*`) -- `Lql.SQLite` / `Lql.Postgres` / `Lql.SqlServer` (real names are `Nimblesite.Lql.*`) -- `Lql.TypeProvider.FSharp` (does not exist on NuGet) -- `MelbourneDev.*` prefixes in `samples.md` (do not exist) -- `Dapper`-style `connection.Query(...)` / `connection.Execute(...)` examples in `quick-start.md` — these are NOT the DataProvider API -- `Samples/` content references a repo (`HealthcareSamples`) that no longer exists - -The samples repo was removed. The reference implementation now lives at `/Users/christianfindlay/Documents/Code/ClinicalCoding` — the **Nimblesite Clinical Coding Platform** (MIT, 2026 Nimblesite, public on GitHub at `MelbourneDeveloper/ClinicalCoding`). The website must advertise this platform in full. - -### Single source of truth: NO CONTENT DUPLICATION - -**CRITICAL**: Component documentation must never be duplicated between component READMEs and the website. The READMEs in each component folder (`DataProvider/README.md`, `Lql/README.md`, `Sync/README.md`, `Gatekeeper/README.md`, `Migration/README.md`) are the canonical source of truth for component-level docs. The website build — specifically [Website/scripts/copy-readmes.cjs](Website/scripts/copy-readmes.cjs) — copies those READMEs into `Website/src/docs/{component}.md` at build time with Eleventy frontmatter prepended. The result is that `/docs/dataprovider/`, `/docs/lql/`, `/docs/sync/`, `/docs/gatekeeper/`, `/docs/migrations/` on the website show the exact same content as the component READMEs. - -Rules: - -- **Never hand-write** `Website/src/docs/dataprovider.md`, `lql.md`, `sync.md`, `gatekeeper.md`, or `migrations.md`. They are generated from READMEs — edit the README instead. -- **Top-level docs** (`installation.md`, `getting-started.md`, `quick-start.md`, `samples.md`) ARE hand-written website content and must not duplicate material that already lives in a component README. They should **link to** `/docs/{component}/` rather than copy-paste. -- If a component README is missing install/usage information that the website needs, fix the README and let `copy-readmes.cjs` publish it — do not add a parallel copy on the website. -- The Playwright stale-package sweep must run against pages generated from READMEs too; if it fails on `/docs/dataprovider/`, the fix goes in `DataProvider/README.md`, not the website. - -The repo is missing a root `LICENSE` file. It must be added as MIT belonging to **Nimblesite Pty Ltd, 2026**. - -No Playwright tests exist for the Website yet. The user wants automated Playwright tests for the updated documentation pages. - -**Goal**: Rewrite all installation/usage docs to match the real shipped packages and CLI tools, add a dedicated Clinical Coding Platform reference-implementation page, add a root MIT license, and add Playwright smoke/verification tests that run against the built site. - ---- - -## Canonical Package & Tool Reference (single source of truth) - -### .NET Global/Local Tools (installed via `dotnet tool install`) - -| Tool package | Command | Version | Purpose | -|---|---|---|---| -| `DataProvider` | `DataProvider` | `0.9.6-beta` | Source-generation CLI — reads `DataProvider.json` + `.sql`/`.lql` files, emits C# extension methods. Subcommands: `sqlite`, `postgres` | -| `DataProviderMigrate` | `DataProviderMigrate` | `0.9.6-beta` | YAML-schema migration CLI. Subcommands: `migrate`, `export` | -| `Lql` | `Lql` | `0.9.6-beta` | LQL → SQL transpiler CLI. Subcommands: `sqlite`, `postgres` | - -`.config/dotnet-tools.json` example (this is how ClinicalCoding consumes them): - -```json -{ - "version": 1, - "isRoot": true, - "tools": { - "dataprovider": { "version": "0.9.6-beta", "commands": ["DataProvider"] }, - "dataprovidermigrate": { "version": "0.9.6-beta","commands": ["DataProviderMigrate"] }, - "lql": { "version": "0.9.6-beta","commands": ["Lql"] } - } -} -``` - -### Library Packages (installed via `dotnet add package`, all v0.9.6-beta) - -| Package | Purpose | -|---|---| -| `Nimblesite.DataProvider.Core` | Shared runtime types for generated code | -| `Nimblesite.DataProvider.SQLite` | SQLite runtime | -| `Nimblesite.DataProvider.Postgres` | Postgres runtime | -| `Nimblesite.DataProvider.SqlServer` | SQL Server runtime | -| `Nimblesite.DataProvider.Migration.Core` | Migration engine core | -| `Nimblesite.DataProvider.Migration.SQLite` | SQLite DDL emitter | -| `Nimblesite.Sql.Model` | SQL AST model used by Lql/DataProvider | -| `Nimblesite.Lql.Core` | LQL parser + analyzer | -| `Nimblesite.Lql.SQLite` | `ToSqlite()` extension | -| `Nimblesite.Lql.Postgres` | `ToPostgreSql()` extension | -| `Nimblesite.Lql.SqlServer` | `ToSqlServer()` extension | -| `Nimblesite.Sync.Core` | Offline-first sync core | -| `Nimblesite.Sync.Http` | HTTP sync transport | -| `Nimblesite.Sync.Postgres` | Postgres sync provider | -| `Nimblesite.Sync.SQLite` | SQLite sync provider | -| `Nimblesite.Reporting.Engine` | Embeddable reporting platform | - -**Packages the docs currently advertise but must be REMOVED** (do not exist on NuGet): -- `DataProvider.MySql`, `DataProvider.Sqlite`, `DataProvider.SqlServer`, `DataProvider.Postgres` -- `Lql.SQLite`, `Lql.Postgres`, `Lql.SqlServer`, `Lql.TypeProvider.FSharp` -- `MelbourneDev.DataProvider`, `MelbourneDev.Lql.Postgres`, `MelbourneDev.Sync`, `MelbourneDev.Sync.Postgres`, `MelbourneDev.Migration`, `MelbourneDev.Migration.Postgres`, `MelbourneDev.Selecta` - ---- - -## Files to modify - -### 1. [Website/src/docs/installation.md](Website/src/docs/installation.md) — full rewrite - -Restructure into three clear sections matching real NuGet reality: - -1. **Install the CLI tools** (as a local tool manifest): - ```bash - dotnet new tool-manifest - dotnet tool install DataProvider --version 0.9.6-beta - dotnet tool install DataProviderMigrate --version 0.9.6-beta - dotnet tool install Lql --version 0.9.6-beta - ``` -2. **Add runtime library packages** for your database (table with `Nimblesite.DataProvider.SQLite|Postgres|SqlServer` + optional `Nimblesite.Lql.*`, `Nimblesite.Sync.*`, `Nimblesite.Reporting.Engine`) -3. **Requirements**: .NET 10 SDK, C# latest, `enable` -4. Link to Getting Started, Quick Start, Clinical Coding Platform - -Remove MySQL. Remove `Lql.TypeProvider.FSharp`. - -### 2. [Website/src/docs/getting-started.md](Website/src/docs/getting-started.md) — full rewrite - -Replace with the **real end-to-end flow** used by `Nimblesite.DataProvider.Example` and `ClinicalCoding`: - -1. Create local tool manifest + install the 3 tools -2. Add `Nimblesite.DataProvider.SQLite` PackageReference -3. Create `example-schema.yaml` → run `dotnet DataProviderMigrate migrate --schema example-schema.yaml --output invoices.db --provider sqlite` -4. Write `GetCustomers.lql` → run `dotnet Lql sqlite --input GetCustomers.lql --output GetCustomers.generated.sql` -5. Write `DataProvider.json` → run `dotnet DataProvider sqlite --project-dir . --config DataProvider.json --out ./Generated` -6. Consume generated `GetCustomersAsync` from C# with `Result` pattern match -7. MSBuild target example (cribbed from `DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj` lines 49-93) showing all three tools wired into pre-compile targets - -Update .NET version to **10.0**. Remove Dapper-esque API. - -### 3. [Website/src/docs/quick-start.md](Website/src/docs/quick-start.md) — full rewrite - -Current content is Dapper, not DataProvider. Replace with a "5-minute query" walkthrough driven entirely by generated extension methods and `Result` pattern-matching. Include: - -- A short `.lql` sample -- The resulting generated method signature -- Consuming code showing `switch` on `Result.Ok` / `.Error` -- A Postgres-targeted second snippet using `Nimblesite.Lql.Postgres.SqlStatementExtensionsPostgreSQL.ToPostgreSql()` - -No raw SQL insert/update/delete snippets (violates project's "no raw SQL" rule). - -### 4. [Website/src/docs/samples.md](Website/src/docs/samples.md) — full rewrite → rename conceptually to "Clinical Coding Platform" - -Keep filename `samples.md` (so existing nav link works), but retitle to "Nimblesite Clinical Coding Platform — Reference Implementation". - -Content sourced from `/Users/christianfindlay/Documents/Code/ClinicalCoding`: - -- Overview: agentic ICD coding from patient encounters + clinical notes using pgvector semantic search -- **Disclaimer**: technology demonstration only, not for production -- Architecture diagram (4 microservices: Clinical, Scheduling, ICD10, Gatekeeper + Dashboard) -- Tech stack table (.NET 10, ASP.NET Core Minimal API, Postgres + pgvector, DataProvider, Lql, Sync, H5 + React 18, MedEmbed, Docker Compose) -- **NuGet packages consumed** table (corrected to real `Nimblesite.*` names, matching what ClinicalCoding's csproj files actually reference) -- **dotnet tools consumed** (`.config/dotnet-tools.json` snippet: DataProvider, DataProviderMigrate, Lql) -- FHIR R5 resources list (Patient, Encounter, Condition, MedicationRequest, Practitioner, Appointment) -- ICD-10 country-agnostic variant support table (CM/AM/GM/CA) -- RAG search `POST /api/search` request/response -- Service endpoint table (Clinical `:5080`, Scheduling `:5001`, ICD10 `:5090`, Dashboard `:8080`) -- Data ownership + sync flow table -- **Getting it running**: `make start-docker` / `make start-local` / `make db-migrate` / `make test` -- Links: `https://github.com/MelbourneDeveloper/ClinicalCoding`, MIT license -- Screenshot: copy `/Users/christianfindlay/Documents/Code/ClinicalCoding/login-after.png` into `Website/src/assets/images/clinical-coding/login.png` and reference it - -### 5. [Website/src/_data/navigation.json](Website/src/_data/navigation.json) — targeted edits - -- Change "Healthcare Samples" link text → "Clinical Coding Platform" (URL stays `/docs/samples/`) -- Remove "F# Type Provider" item (package doesn't exist) -- Keep existing DataProvider/LQL/Sync/Migrations/Gatekeeper component links (the `copy-readmes.cjs` script generates those pages from each component's README — they stay auto-generated; we only need to confirm the READMEs themselves have accurate install blocks — out of scope for this plan but flag it) - -### 6. [Website/src/docs/blog](Website/src/blog) — targeted edits - -Three blog posts reference stale package names. Replace with real package names: - -- [Website/src/blog/getting-started-dataprovider.md:21](Website/src/blog/getting-started-dataprovider.md#L21) -- [Website/src/blog/connecting-sql-server.md:22](Website/src/blog/connecting-sql-server.md#L22) -- [Website/src/blog/lql-simplifies-development.md:71](Website/src/blog/lql-simplifies-development.md#L71) - -### 7. [Website/src/index.njk](Website/src/index.njk) — homepage tweaks - -- Update the hero's "Source-Generated SQL" card to mention the **CLI-driven build-time code generation** model (not a Roslyn analyzer — the current wording implies Roslyn) -- Add a "Reference Implementation: Clinical Coding Platform" card in the features grid linking to `/docs/samples/` -- Verify any package-name mentions on the page are correct - -### 8. Root `LICENSE` file — new file (MIT 2026 Nimblesite Pty Ltd) - -Create `/Users/christianfindlay/Documents/Code/DataProvider/LICENSE` with the standard MIT license text, copyright line: - -``` -Copyright (c) 2026 Nimblesite Pty Ltd -``` - -Also add a short "License" section at the bottom of the homepage README referencing it (if repo README lacks one — to be verified during execution). - -### 9. [Website/scripts/copy-readmes.cjs](Website/scripts/copy-readmes.cjs) — no change expected - -This script copies component READMEs (DataProvider, Lql, Sync, Gatekeeper, Migration) into `src/docs/` at build time. The READMEs themselves may still have stale package names — spot-check during execution and fix where needed. **Do not** modify this script; fix the underlying READMEs. - -### 10. [Website/package.json](Website/package.json) — add Playwright - -Add dev dependency `@playwright/test` and scripts: - -```json -"test:e2e": "playwright test", -"test:e2e:install": "playwright install chromium" -``` - -### 11. New file: `Website/playwright.config.ts` - -- `webServer`: runs `npm run build && npx http-server _site -p 8123` (or uses eleventy `--serve`) -- Single project: chromium, headless, baseURL `http://localhost:8123` -- Retries 0, workers 1 — deterministic - -### 12. New file: `Website/tests/e2e/docs.spec.ts` - -Playwright test suite that: - -1. **Homepage smoke**: loads `/`, asserts hero heading visible, asserts at least one link to `/docs/samples/`, asserts NO occurrence of `MelbourneDev.`, `DataProvider.MySql`, or `Lql.TypeProvider.FSharp` anywhere in the DOM -2. **Installation page**: loads `/docs/installation/`, asserts presence of the three real tool names (`DataProvider`, `DataProviderMigrate`, `Lql`) in code blocks, asserts presence of `Nimblesite.DataProvider.SQLite`, asserts absence of stale names -3. **Getting Started page**: asserts presence of `dotnet tool install DataProvider`, `DataProviderMigrate migrate`, `Lql sqlite`, and `Result<` (Result type usage) -4. **Quick Start page**: asserts NO `connection.Query<` or `connection.Execute(` (Dapper API removed), asserts presence of `Result<` pattern matching -5. **Clinical Coding Platform page** (`/docs/samples/`): asserts page title contains "Clinical Coding", asserts link to `github.com/MelbourneDeveloper/ClinicalCoding`, asserts FHIR + ICD-10 + pgvector mentioned, asserts login screenshot `` loads (response 200) -6. **Navigation page**: asserts nav contains "Clinical Coding Platform" text, does NOT contain "F# Type Provider" -7. **Broken-link sweep**: crawls all internal `/docs/*` links from the nav and asserts every response is 200 -8. **Stale-package global sweep**: fetches every `/docs/*` and `/blog/*` page and asserts none contain the forbidden strings list - -### 13. CI wiring (out of scope note) - -The Website currently builds via `make` but has no `test:e2e` step in CI. Flag this for a follow-up; do not touch CI workflows in this pass unless explicitly asked. - ---- - -## Existing code/utilities reused - -- [Website/scripts/copy-readmes.cjs](Website/scripts/copy-readmes.cjs) — already generates `dataprovider.md`, `lql.md`, `sync.md`, `gatekeeper.md`, `migrations.md` from component READMEs. Don't duplicate. -- [Website/src/_includes/layouts/docs.njk](Website/src/_includes/layouts/docs.njk) — existing docs layout, reuse -- `Nimblesite.DataProvider.Example.csproj` lines 49-93 — real MSBuild target wiring for all three tools, copy verbatim into getting-started.md -- `Migration/DataProviderMigrate/Program.cs` lines 286-333 — real CLI help text for copy-paste into the migrations section -- `Lql/Lql/SqliteCli.cs` lines 20-43 — real Lql CLI flags -- `/Users/christianfindlay/Documents/Code/ClinicalCoding/.config/dotnet-tools.json` — real tool-manifest example (note: bump version from 0.9.5-beta to 0.9.6-beta for DataProviderMigrate/Lql when documenting) -- `/Users/christianfindlay/Documents/Code/ClinicalCoding/Makefile` — real `make` targets for the platform - ---- - -## Verification - -1. **Build**: `cd Website && npm run build` — eleventy build succeeds, no broken template references -2. **Visual smoke**: `cd Website && npm run dev` and manually open `/`, `/docs/installation/`, `/docs/getting-started/`, `/docs/quick-start/`, `/docs/samples/` in a browser — spot-check that the Clinical Coding screenshot renders, code blocks are syntax-highlighted, and nav shows "Clinical Coding Platform" -3. **Playwright**: - ```bash - cd Website - npm run test:e2e:install - npm run test:e2e - ``` - All tests (homepage smoke, installation, getting-started, quick-start, clinical coding page, nav, broken-link sweep, stale-package sweep) must pass -4. **Stale-string sanity**: `grep -rE "(MelbourneDev\.|DataProvider\.MySql|DataProvider\.Sqlite[^.]|Lql\.TypeProvider|HealthcareSamples)" Website/src` — must return zero matches -5. **License**: `cat LICENSE | head -3` — must show `MIT License` and `Copyright (c) 2026 Nimblesite Pty Ltd` -6. **Clinical Coding asset**: `ls Website/src/assets/images/clinical-coding/login.png` exists and is non-empty - ---- - -## Out of scope (flag but don't fix in this pass) - -- Component README files (`DataProvider/README.md`, `Lql/README.md`, etc.) — may contain stale package names. Will need a separate pass once the top-level docs are fixed, since `copy-readmes.cjs` pipes them into `/docs/*` at build time. If Playwright stale-package sweep fails on an auto-generated page, fix the underlying README as part of this task. -- DocFX API reference pages (`Website/docfx/`) — these regenerate from XML doc comments in csproj; unchanged by this task. -- CI workflow (`.github/workflows/*`) — do not touch unless user asks. diff --git a/docs/specs/PROBLEM-cli-vs-library.md b/docs/specs/PROBLEM-cli-vs-library.md index 08cb1d34..112b0043 100644 --- a/docs/specs/PROBLEM-cli-vs-library.md +++ b/docs/specs/PROBLEM-cli-vs-library.md @@ -176,11 +176,7 @@ always sees outputs as missing and re-runs every time). Because the CLI emits files, the consumer has to choose: commit them to git (stale, churn-prone, merge-conflict magnet) or gitignore them (every fresh clone needs the CLI installed and Postgres running before `dotnet build` works -at all). Both options are bad. The HealthcareSamples repo has a 300-line plan -document -([docs/plans/delete-generated-files-and-postgres-codegen.md](file:///Users/christianfindlay/Documents/Code/HealthcareSamples/docs/plans/delete-generated-files-and-postgres-codegen.md)) -whose entire reason for existing is to fix the consequences of this one -architectural choice. +at all). Both options are bad. The [ClinicalCoding repository](https://github.com/Nimblesite/ClinicalCoding) documents the consequences of this architectural choice. ### 5. The build needs a live database to compile @@ -229,12 +225,12 @@ from one project to another has no way to know whether the tool exists, whether the command name is right, or whether a newer compatible version is on nuget.org. -When it goes wrong, the failure mode is the one HealthcareSamples is hitting +When it goes wrong, the failure mode is the one ClinicalCoding is hitting right now: ``` ==> Linting... -dotnet build HealthcareSamples.sln --configuration Release +dotnet build --configuration Release Could not execute because the specified command or file was not found. * You intended to execute a .NET program, but dotnet-lql-postgres does not exist. @@ -263,11 +259,11 @@ modes documented above are well-understood industry-wide. ## Symptom log: where this is currently biting -- **HealthcareSamples** CI is red because `dotnet-lql-postgres` is not on +- **ClinicalCoding** CI is red because `dotnet-lql-postgres` is not on nuget.org and never was. The plan that introduced the dependency on it - ([delete-generated-files-and-postgres-codegen.md](file:///Users/christianfindlay/Documents/Code/HealthcareSamples/docs/plans/delete-generated-files-and-postgres-codegen.md)) + ([ClinicalCoding repository](https://github.com/Nimblesite/ClinicalCoding)) cited a version (`0.1.8-beta`) of a package that does not exist. -- **HealthcareSamples** has had at least four commits in a row trying to fix +- **ClinicalCoding** has had at least four commits in a row trying to fix the dotnet-tools manifest (`fix tools list`, `fix`, `DataProvider version`, `move version to build build props`) without resolving the underlying "the tool isn't real" problem. @@ -376,7 +372,7 @@ No `RemoveDir`/`MakeDir`/`Touch`/``. No to the consumer's runtime — consumers can still reference Npgsql 9 (or any other version) for their own runtime data access. NOT ILRepack/ILMerge — too risky and brittle. -- **HealthcareSamples build environment:** Postgres is already brought up +- **ClinicalCoding build environment:** Postgres is already brought up before `dotnet build` in `make ci` (`docker compose up -d postgres` → `db-migrate` → `dotnet build`). Same flow stays. Build will fail loud if Postgres is down. This is the intended contract. diff --git a/docs/specs/ci-spec.md b/docs/specs/ci-spec.md new file mode 100644 index 00000000..4a19cf84 --- /dev/null +++ b/docs/specs/ci-spec.md @@ -0,0 +1,65 @@ +# CI and Local Verification + +## Release CI Gate [CI-RELEASE-GATE] + +Tag releases call the same reusable CI workflow used by pull requests and the +CodeQL workflow before publishing packages, binaries, or VSIX files. Both +workflows check out the tagged SHA. The CI aggregate requires success from +every build, test, lint, and duplication job; only dependency review may be +skipped for non-PR events. CI also runs `tools/test-version-stamp.py`, which +checks stable and prerelease stamping and dry-run immutability. + +## Rust Coverage Instrumentation [CI-RUST-COVERAGE] + +`make test` and CI use Tarpaulin's LLVM engine explicitly on every platform. +Linux must install `llvm-tools-preview`. Auto-selection previously used ptrace +on Linux and LLVM on macOS, which counted different executable lines and made +the locally ratcheted 98% floor inconsistent. Keep the same 98% floor, tests, +and generated-parser/test-file exclusions with the explicit common engine. + +## Initial Duplication Gate Baseline [CI-DESLOP-BASELINE] + +This PR introduces `.deslop.toml` and the Deslop CI gate; neither existed on +main. The initial 5% setting did not measure the existing repository and made +the new gate fail even on unchanged main. This section is the written +justification for correcting that initial adoption setting. It does not +authorize increasing an established gate in future changes. + +With Deslop **0.36.0**, the same exclusions, and no hidden findings, the scans +on 2026-10-04 produced: + +| Source | Analysed LOC | Duplicated LOC | Duplication | +| --- | ---: | ---: | ---: | +| Main `8e3af366e20d1b8db548fdbf20c23314a6a1927a` | 107,436 | 47,489 | 44.20212964% | +| PR source after shared helpers and security regressions | 116,630 | 45,993 | 39.43496527% | + +The adoption ceiling is **39.44%**, rounded to two decimal places from the +measured PR source. This is below main's measured baseline and provides no +growth allowance beyond rounding. The PR removes 1,496 measured duplicated +lines. Real duplication remains; this baseline is not a claim that the debt +is resolved. The gate continues scanning the whole configured scope and +uploading every finding. No new exclusions or hidden clusters were added to +obtain this baseline. Subsequent reductions must ratchet the ceiling down. +The unrelated-assertion false-positive evidence is retained in +[Deslop issue #534](https://github.com/Nimblesite/Deslop/issues/534#issuecomment-5969944576). + +To reproduce, extract `git archive` of the main SHA above into a temporary +directory, copy this PR's `.deslop.toml` into it, and run `deslop .` using +0.36.0 in that directory. Run the same command in the PR checkout. Compare +`metrics.analysed_loc`, `metrics.duplicated_loc`, and +`metrics.duplication_percent` in each `.deslop/deslop-report.json`. +The former 5% value and 39.44% value change only the pass/fail ceiling, not +the scan or its findings. + +## Local ClinicalCoding Testing [CI-CLINICAL-LOCAL] + +The healthcare reference app lives in its own repository, +[Nimblesite/ClinicalCoding](https://github.com/Nimblesite/ClinicalCoding). No +sample or clinical code lives in this repository. To prove a DataProvider change +does not regress it, clone ClinicalCoding next to this checkout and run +`make clinical`. + +`tools/clinical-local.sh` packs every package and dotnet tool from this checkout +into `artifacts/local-feed` with a unique `0.0.0-local.` version, then +runs ClinicalCoding's make targets (default `lint test build`) with that version +and feed. The ClinicalCoding working tree is restored on exit. diff --git a/docs/specs/codegen-cli-tool.md b/docs/specs/codegen-cli-tool.md index 6ca32e58..cb2936c4 100644 --- a/docs/specs/codegen-cli-tool.md +++ b/docs/specs/codegen-cli-tool.md @@ -221,7 +221,7 @@ Auto-imported by NuGet from `build/DataProvider.targets`. Consumer never sees th | TEST-NATIVE | Live-db only. Matrix runs on `win-x64`, `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64`. Each combo loads its driver's native asset from `runtimes/` and runs an introspection against a testcontainer. Schema-doc mode bypasses native drivers entirely. | | TEST-PARSER | Per platform, `Nimblesite.DataProvider.{Platform}.Tests` parses real fixture SQL and asserts on the resulting syntax tree shape (rule contexts, terminal nodes, parameter list, projection list). Mandatory for every platform under [CON-PARSER-ONLY]. | | TEST-WATCH | Boot the LSP against a temp solution with two projects, edit one column in one project's `*.schema.yaml`, assert only that project's `.g.cs` rewrites; sibling project keeps mtime. Snapshot test on the typeDiagram-generated TS types confirms the wire surface. | -| TEST-TEMPLATES | Sample TypeScript template under `Samples/` emits `*.d.ts` for the `example` schema; `tsc --noEmit` passes. Sandbox test asserts a malicious template that touches `System.IO` is rejected with `DPSG014`. | +| TEST-TEMPLATES | TypeScript test fixture emits `*.d.ts` for the `example` schema; `tsc --noEmit` passes. Sandbox test asserts a malicious template that touches `System.IO` is rejected with `DPSG014`. | ## DX diff --git a/docs/specs/declarative-triggers-spec.md b/docs/specs/declarative-triggers-spec.md new file mode 100644 index 00000000..b7bf738d --- /dev/null +++ b/docs/specs/declarative-triggers-spec.md @@ -0,0 +1,90 @@ +# Declarative Trigger Guards (GitHub issue 82) + +User-declared BEFORE triggers as first-class schema objects: diffed, inspected, +and dropped destructively, exactly like RLS policies. Motivating case: +last-owner protection on `tenant_members` (refuse to demote/delete the only +`owner` of a tenant), which RLS predicates cannot express. + +## [MIG-TRIGGER-YAML] YAML shape + +Triggers are declared per table under `triggers:`. + +```yaml +tables: + - name: tenant_members + triggers: + - name: assert_not_last_owner + timing: Before # Before (default) or After + events: [Update, Delete] # Insert | Update | Delete + forEachRow: true # default true; false is rejected (guards are row-level) + raiseWhen: | # LQL guard predicate; row refs via old./new. + old.role = 'owner' and not exists( + tenant_members + |> filter(fn(m) => m.tenant_id = old.tenant_id and m.role = 'owner' and m.id <> old.id) + ) + errorMessage: cannot remove the last owner of a tenant +``` + +The trigger raises `errorMessage` and aborts the statement when `raiseWhen` +evaluates true for the affected row. `raiseWhen` + `errorMessage` is the +declarative, platform-independent form of the issue's `bodyLql` proposal. + +## [MIG-TRIGGER-MODEL] Model + +`TriggerDefinition` record (Name, Timing, Events, ForEachRow, RaiseWhenLql, +ErrorMessage) on `TableDefinition.Triggers`. Enums `TriggerTiming` +(Before/After) and `TriggerEvent` (Insert/Update/Delete). Closed, immutable, +YAML round-trippable with defaults omitted. + +## [MIG-TRIGGER-DIFF] Diff semantics + +Triggers are diffed by name per table (mirror of RLS policy diff): +missing in current → `CreateTriggerOperation`; present in current but removed +from desired → `DropTriggerOperation` only when `allowDestructive` (the drop +is logged at Warning), otherwise skipped. `DropTriggerOperation` is +destructive and gated by `MigrationOptions.AllowDestructive`. + +## [MIG-TRIGGER-GUARD-LQL] Guard predicate LQL + +`raiseWhen` is an LQL boolean expression over the affected row and the +database. Row column references use `old.` / `new.` +(case-insensitive prefix). Supports simple comparisons, `and`/`or` +composition, and `exists(...)` / `not exists(...)` pipeline subqueries. +Transpiles identically (same semantics) on every supported platform. + +Validation (all fail the migration loudly rather than diverging at runtime): + +- `old.` references require every event to be Update/Delete; `new.` + references require Insert/Update (OLD does not exist on INSERT, NEW does + not exist on DELETE). +- `forEachRow: false` is rejected -- guards are row-level on every platform + (SQLite has no statement triggers; Postgres statement triggers see NULL + OLD/NEW and would silently never fire). +- The token `__TRG_` is reserved for internal sentinels; predicates + containing it are rejected so user literals can never be rewritten. + +On Postgres, transpiled `exists()` pipeline SQL gets an identifier-quoting +pass so mixed-case column names are not case-folded. + +## [MIG-TRIGGER-SQLITE] SQLite emission and inspection + +One trigger per event named `usr_{event}_{trigger}_{table}`: +`CREATE TRIGGER ... BEFORE {EVENT} ON [table] FOR EACH ROW BEGIN +SELECT RAISE(ABORT, '{errorMessage}') WHERE {predicate}; END`. +Inspector reads `usr_`-prefixed triggers back from `sqlite_master` (grouped by +trigger name, `rls_` triggers excluded) so re-diff is a no-op. + +## [MIG-TRIGGER-PG] PostgreSQL emission and inspection + +One plpgsql function `{table}_{trigger}_trgfn` raising `errorMessage` when the +predicate holds (`RAISE ... USING MESSAGE` avoids `%` format interpretation; +the dollar-quote tag is chosen to never occur in the body), plus one trigger +`usr_{trigger}` (`BEFORE {events} ... FOR EACH ROW EXECUTE FUNCTION`). Drop +removes trigger and function. Identifiers over PostgreSQL's 63-byte limit are +rejected loudly (silent truncation would desynchronise create/drop/inspect). + +Inspector reads only `usr_`-prefixed triggers back from +`information_schema.triggers` grouped by trigger name (prefix stripped) so +re-diff is a no-op and triggers created outside the migration tool are never +dropped by destructive runs. `*_trgfn` guard functions are excluded from +support-function read-back because they are owned by the trigger lifecycle. diff --git a/docs/specs/lql-spec.md b/docs/specs/lql-spec.md index fcde9860..a8a7c943 100644 --- a/docs/specs/lql-spec.md +++ b/docs/specs/lql-spec.md @@ -4,7 +4,7 @@ Lambda Query Language (Lql) is a functional–pipeline–style DSL that transpiles to procedural SQL or pure SQL for popular RDBMSs. -### Pipeline syntax +### Pipeline syntax [LQL-PIPELINE-COMPOSITION] Chained operations using `|>`: @@ -12,6 +12,11 @@ Chained operations using `|>`: table |> join(other, on = …) |> filter(…) |> select(…) |> insert(…) ``` +Each appended operation must retain the earlier operations' meaning when +transpiled for PostgreSQL, SQLite, or SQL Server. Adding a join and filter must +keep the selected source; adding ordering and a limit must keep that join and +filter while applying the requested ordering and row limit. + ### Constructs | Feature | Description | | | @@ -31,6 +36,45 @@ membership in a non-empty literal list. Pipeline filter bodies, including target platform. This supports compact role-membership predicates such as `m.role in ('owner', 'admin')` without expanding to `OR` chains. +### Common Table Expressions [LQL-CTE] + +`with name as (pipeline), other as (pipeline) main-pipeline` names pipelines that +the main pipeline (and later CTEs) can use as tables. It transpiles to +`WITH name AS (...), other AS (...) SELECT ...` on every dialect. + +``` +with high_value_customers as ( + orders |> group_by(orders.user_id) |> having(fn(g) => sum(orders.total) > 10000) |> select(orders.user_id) +) +users |> join(high_value_customers, on = users.id = high_value_customers.user_id) |> select(users.id) +``` + +### Derived Tables [LQL-DERIVED-TABLE] + +`join((pipeline), on = ...)` joins the result of a parenthesized pipeline. The +derived table is aliased after its base table, and outer references to that base +table (`orders.order_count`) resolve to the derived table. + +### Subquery Layout [LQL-SUBQUERY-LAYOUT] + +Statements with CTEs or derived tables are rendered identically on SQLite, +PostgreSQL and SQL Server, one clause per line: + +- Queries that join sources alias each source by the initials of its + underscore-separated name (`users` → `u`, `high_value_customers` → `hvc`, with a + numeric suffix on collision) and qualify columns with those aliases. +- A standalone single-table body (a CTE or derived table) drops its table + qualifiers; EXISTS/IN bodies keep them, because they may correlate with the + outer query. +- Nested bodies are indented four spaces inside `(` … `)`. +- Several `filter` steps are ANDed, each parenthesized so an `OR` inside one + filter cannot bind across the `AND`. +- Only paging differs by dialect: `LIMIT`/`OFFSET` on SQLite and PostgreSQL, + `TOP n` or `OFFSET … ROWS FETCH NEXT … ROWS ONLY` on SQL Server. LIMIT/OFFSET + inside EXISTS/IN bodies are rejected rather than dropped. +- SQLite otherwise renders statements on one line; a WHERE holding a multi-line + subquery switches it to one clause per line. + Futures: join(table2, on = …) @@ -54,10 +98,11 @@ limit(n) | `union(other)` | SQL `UNION` | | `range(a,b)` | generates a range | -### Output +### Output [LQL-OUTPUT-DIALECTS] * Target SQL dialect chosen at transpilation (`postgres`, `mysql`, `sqlserver`, etc.) * Defaults to PostgreSQL if not specified. +* Equivalent select, filter, join, grouping, ordering, and paging requests retain their meaning on PostgreSQL, SQLite, and SQL Server. A target dialect that cannot render a requested statement must return an error rather than silently dropping an operation. ### Identifier Casing @@ -76,12 +121,10 @@ This guarantees portability: ### Validation Rules -#### Identifier Validation +#### Identifier Validation [LQL-IDENTIFIER-VALIDATION] - **Numeric Start**: Identifiers cannot start with numbers (e.g., `123table` is invalid) -- **Undefined Variables**: Identifiers containing underscores that appear as pipeline bases are treated as undefined variables and result in syntax errors - - Invalid: `undefined_variable |> select(name)` → "Syntax error: Undefined variable" - - Valid: `users |> select(name)` → Simple table names without underscores are allowed +- **Underscored Table Names**: An identifier such as `tenant_members` is a valid pipeline base. The parser cannot distinguish an unknown variable from a table name without schema metadata; any undefined-variable check requires a later semantic pass with table context. #### Error Handling diff --git a/docs/specs/migration-spec.md b/docs/specs/migration-spec.md index c88619d7..a8b7d8d3 100644 --- a/docs/specs/migration-spec.md +++ b/docs/specs/migration-spec.md @@ -542,7 +542,7 @@ The diff engine produces a list of schema operations as discriminated union reco All operations carry the schema name, table name, and relevant definition or constraint name. -### 6.2 Additive-Only Mode (Default) +### 6.2 Additive-Only Mode (Default) [MIG-DIFF-ADDITIVE] By default, the migration engine only applies **additive** operations: @@ -556,9 +556,18 @@ By default, the migration engine only applies **additive** operations: | `DropTable` | **No** - requires explicit opt-in | | `DropColumn` | **No** - requires explicit opt-in | | `DropIndex` | **No** - requires explicit opt-in | -| `AlterColumn` | **No** - requires explicit opt-in | +| `AlterColumn` | Only `NOT NULL` to nullable is allowed by default; other alterations require explicit opt-in | -### 6.3 Destructive Operations +Relaxing an existing column from `NOT NULL` to nullable is additive. A normal +migration must apply the change on every provider and preserve the column's +other constraints. Reapplying the same schema must leave the column nullable. + +Adding a foreign key to an existing table is additive, including when the +referencing column or referenced table is added in the same migration. If an +earlier attempt created the column but not its foreign key, the next normal +migration must add the missing key without duplicating existing objects. + +### 6.3 Destructive Operations [MIG-DIFF-DESTRUCTIVE] Destructive operations require explicit opt-in via `MigrationOptions`: @@ -567,6 +576,11 @@ Destructive operations require explicit opt-in via `MigrationOptions`: - `AllowDropIndex` (default: false) - `AllowAlterColumn` (default: false) +Destructive cleanup removes only objects absent from the desired schema. +Every foreign key still declared in that schema must remain present and +enforced, including when another foreign key or table is removed. Reapplying +the desired schema must not remove or duplicate any surviving key. + ### PostgreSQL Constraint-Backed Index Drops [MIG-PG-CONSTRAINT-BACKED-INDEX-DROP] When a destructive diff emits `DropIndex` for a PostgreSQL index that implements @@ -795,7 +809,31 @@ The following patterns are not conformant: | Columns | Match by name within table (case-insensitive) | | Indexes | Match by name (case-insensitive) | | Primary Keys | Match by table (only one per table) | -| Foreign Keys | Match by name (case-insensitive) | +| Foreign Keys | Match named constraints by name and relationship; match unnamed declarations by relationship. Compare referencing column, referenced table and column, and delete action. | + +### 8.2.1 Existing-database upgrades [MIG-EXISTING-DATABASE-UPGRADE] + +An additive migration relaxes an existing `NOT NULL` column when the +desired schema marks it nullable. A foreign key declared without a name is +created on an existing table after its columns and referenced tables exist. +Re-running a partially applied migration repairs a missing foreign key. +Destructive cleanup drops only foreign keys absent from the desired schema; +repeated additive and destructive runs produce no further operations. + +#### Foreign-Key Repair [MIG-DIFF-FOREIGN-KEY] + +A missing declared foreign key must produce an `AddForeignKey` operation on an +existing table, whether the referencing column already exists or is added by +the same migration. A later run must repair a partially migrated database in +which the column exists but the key does not. A matching existing key must not +be duplicated. + +#### Native PostgreSQL Test Server [MIG-TEST-NATIVE-POSTGRES] + +The shared PostgreSQL test fixture accepts `DATAPROVIDER_POSTGRES_TEST_CONNECTION` +for an isolated native test server. Tests still create a fresh database per case. +The fixture disposes containers that it owns and leaves an externally supplied +server running. Without the variable, the existing container fixture is used. ### 8.3 Diff Algorithm @@ -874,6 +912,17 @@ SQLite-specific considerations: - No DROP COLUMN before SQLite 3.35 (requires table rebuild) - Transactional DDL supported +#### SQLite Table Rebuild [MIG-SQLITE-REBUILD] + +SQLite cannot ALTER nullability, foreign keys, check or unique constraints, or +drop columns. `SqliteMigrationApplier` replaces those operations with one +`RebuildTableOperation` per table, placed before any other operation on that +table, following https://www.sqlite.org/lang_altertable.html#otheralter: create +the declared shape, copy rows, drop, rename, recreate indexes and replay +triggers. Live columns the schema no longer declares are kept unless dropped. +`foreign_keys` is off during the run so DROP TABLE fires no ON DELETE actions; +rebuilt tables are then checked with `PRAGMA foreign_key_check`. + ### 9.3 PostgreSQL Provider PostgreSQL-specific considerations: @@ -884,14 +933,22 @@ PostgreSQL-specific considerations: - Transactional DDL supported - Case-sensitive identifiers (lowercase by default) -### 9.4 SQL Server Provider +### 9.4 SQL Server Provider [MIG-SQLSERVER] -SQL Server-specific considerations: +`Nimblesite.DataProvider.Migration.SqlServer` provides `SqlServerDdlGenerator`, +`SqlServerSchemaInspector` and `SqlServerTypeMapper`; `DataProviderMigrate +--provider sqlserver` (alias `mssql`) runs the shared inspect, diff, apply and +verify pipeline. -- NVARCHAR for Unicode strings +- NVARCHAR for Unicode strings; `Text` maps to NVARCHAR(MAX) - UNIQUEIDENTIFIER for UUIDs -- Limited transactional DDL -- Schema support (dbo, etc.) +- Portable default schemas (`public`, `main`, empty) map to `dbo` +- Constraint names default to `PK_`, `UQ_
    _`, `FK_
    _` +- Relaxing NOT NULL reads the live column type from `sys.columns` because + `ALTER COLUMN` must restate it +- Not supported (fails loudly): RLS ([RLS-MSSQL]), triggers, roles, grants, + functions, expression indexes, LQL defaults, `RESTRICT` foreign key actions +- Known drift: `NVarChar(max)` and `Json` read back as `Text` --- @@ -931,7 +988,7 @@ An implementation is **conformant** if: 9. All public members have XML documentation 10. Logging via ILogger at appropriate levels 11. E2E tests cover greenfield creation and upgrade scenarios -12. E2E tests run against real databases (SQLite in-memory, PostgreSQL via Testcontainers) +12. The same E2E behavior tests run against real SQLite files, PostgreSQL via Testcontainers, and SQL Server via Testcontainers --- @@ -951,23 +1008,29 @@ End-to-end tests are **critical** for validating that migrations work correctly ### 12.2 Greenfield Tests -Create fresh database, define schema with fluent API, apply via `SchemaDiff.Calculate()` + `MigrationRunner.Apply()`, verify tables exist via introspection. +Create a fresh database from a YAML schema through `DataProviderMigrate`, then verify the created objects through database catalog queries. ### 12.3 Upgrade Tests Apply v1 schema, then v2 with new columns. Verify diff produces `AddColumn` operations and final schema has all columns. -### 12.4 PostgreSQL Tests with Testcontainers +### 12.4 PostgreSQL and SQL Server Tests with Testcontainers -Use `Testcontainers.PostgreSql` to spin up real PostgreSQL. Verify native types (UUID, JSONB, TIMESTAMPTZ) are created correctly by querying `information_schema.columns`. +Use `Testcontainers.PostgreSql` and `Testcontainers.MsSql` to run real server +instances. Both providers must execute the same behavioral migration theories +as SQLite. Verify provider-specific types and constraints through catalog queries +where necessary; a missing implementation must cause an ordinary test failure. ### 12.5 Idempotency Tests Run migration twice. Second run should produce zero operations (schema already matches desired state). -### 12.6 Cross-Platform Test Matrix +### 12.6 Cross-Platform Test Matrix [MIG-TEST-CROSS-PLATFORM] -Use `[Theory]` with `[MemberData]` to run the same schema definition against SQLite, PostgreSQL, and SQL Server. Verify identical results across all platforms. +Use a single `[Theory]` with rows for SQLite, PostgreSQL, and SQL Server to run +each behavioral migration scenario. Assert the same resulting schema and +idempotency on all three, with provider-specific catalog queries only for +readback. Do not skip or accept expected failures for any provider. ### 12.7 Required Test Coverage diff --git a/docs/specs/rls-spec.md b/docs/specs/rls-spec.md index c1a857a2..720e1da1 100644 --- a/docs/specs/rls-spec.md +++ b/docs/specs/rls-spec.md @@ -260,7 +260,7 @@ Operation order is schema-safe: roles first, table DDL second, support functions ## 8. SQL Server Implementation [RLS-MSSQL] -> **Status:** SQL Server package (`Nimblesite.DataProvider.Migration.SqlServer`) does not exist yet. This section defines the contract; implementation is deferred until that package ships. Any `CreateRlsPolicyOperation` targeting SQL Server before the package exists MUST emit `MIG-E-RLS-MSSQL-UNSUPPORTED`. +> **Status:** The SQL Server migration package supports table and constraint migrations. Native RLS migration and inspection are not implemented. This section defines the deferred native RLS contract. Any RLS schema targeting SQL Server MUST emit `MIG-E-RLS-MSSQL-UNSUPPORTED` before changing the database until native RLS support is implemented. SQL Server RLS uses a two-step approach: 1. An inline table-valued function (iTVF) as a filter or block predicate @@ -377,10 +377,30 @@ Multiple policies on the same table combine as AND conditions inside a single tr ## 10. Schema Diff [RLS-DIFF] +For an existing PostgreSQL policy, compare both `USING` and `WITH CHECK` +predicates after PostgreSQL expression parsing. A changed predicate produces +an `AlterRlsPolicyOperation` even when the policy name is unchanged. Schema +integrity verification also reports predicate drift. Re-inspecting and +reapplying a converged policy produces no operation. + +On SQLite, read `USING` from the secure view and `WITH CHECK` from the managed +triggers using the SQLite SQL parser and tokenizer. SQL comments and quoted +identifiers must not interfere with readback. Include the secure view's SELECT +operation when reconstructing an ALL policy so an unchanged rerun is a no-op. +If stored predicates cannot be read, reconcile the policy with its desired +definition; an unknown predicate must never be treated as verified equality. + +The secure view must also be inspected when no RLS trigger exists. Generated +views retain their policy name in a hex-encoded SQL comment, read with the SQL +tokenizer, so SELECT-only policies can converge by name and predicate. Legacy +views without this metadata are replaced transactionally with the declared +policy instead of preserving the old predicate through `IF NOT EXISTS`. + `SchemaDiff.Calculate` gains RLS diff logic comparing `TableDefinition.RowLevelSecurity` between current and desired schemas: - Table exists in desired with RLS enabled but not in current -> emit `EnableRlsOperation` then `CreateRlsPolicyOperation` for each policy - Policy in desired not in current -> emit `CreateRlsPolicyOperation` +- Policy name exists in both but its `USING` or `WITH CHECK` predicate differs -> replace or alter that policy so both predicates match the desired definition. Compare each clause independently; an unchanged clause must retain its meaning. - Policy in current but not in desired (with `allowDestructive: true`) -> emit `DropRlsPolicyOperation` - RLS disabled in desired but enabled in current (with `allowDestructive: true`) -> emit `DisableRlsOperation` @@ -396,7 +416,7 @@ Schema inspectors (`PostgresSchemaInspector`, `SqliteSchemaInspector`) must be e | `MIG-E-RLS-EMPTY-CHECK` | Policy has INSERT/UPDATE operations but no `WithCheckLql` | | `MIG-E-RLS-LQL-PARSE` | `UsingLql` / `WithCheckLql` failed LQL parse | | `MIG-E-RLS-LQL-TRANSPILE` | LQL transpilation to platform SQL failed | -| `MIG-E-RLS-MSSQL-UNSUPPORTED` | SQL Server RLS attempted before `SqlServer` package ships | +| `MIG-E-RLS-MSSQL-UNSUPPORTED` | SQL Server RLS attempted before native RLS migration support is implemented | | `MIG-W-RLS-SQLITE-SELECT-VIEW` | Informational: SELECT policy enforced via `_secure` view, not triggers | | `MIG-W-RLS-SQLITE-RESTRICTIVE-APPROX` | RESTRICTIVE policy approximated as AND condition in SQLite triggers | diff --git a/docs/specs/sql-model-spec.md b/docs/specs/sql-model-spec.md new file mode 100644 index 00000000..8736ef15 --- /dev/null +++ b/docs/specs/sql-model-spec.md @@ -0,0 +1,11 @@ +# SQL Model Portability + +## Dialect Rendering [DP-SQL-MODEL-DIALECTS] + +The `SelectStatement` builder represents a database-independent query. Every +select, filter, join, grouping, ordering, and paging request supported by the +builder must render to SQLite, PostgreSQL, and SQL Server SQL. The same +behavioral test cases must exercise all three renderers. If a provider has no +renderer, the test fails; it must not be skipped or marked as an expected +failure. Each rendered statement must retain the requested columns, predicates, +joins, and row constraints, using the target provider's paging syntax. diff --git a/docs/specs/sync-spec.md b/docs/specs/sync-spec.md index 4e036e04..eff3e48f 100644 --- a/docs/specs/sync-spec.md +++ b/docs/specs/sync-spec.md @@ -57,6 +57,15 @@ This specification does **not** cover: - SQL Server (can leverage native Change Tracking) - PostgreSQL (can leverage logical replication) +#### [SYNC-HTTP-PROVIDERS] Current HTTP implementation + +The shipped replica providers and HTTP sync operations support SQLite and PostgreSQL. +SQL Server remains a target platform; no SQL Server sync replica provider is implemented. +HTTP requests for that provider currently return `ProblemDetails` with status 500 and +`Unknown database type: sqlserver`, before opening or modifying the target database. +Provider regression tests must verify this rejection and an unchanged catalog. The +SQLite and PostgreSQL cases must continue verifying real reads and applied changes. + --- ## 2. Goals & Non-Goals @@ -1011,4 +1020,4 @@ CREATE TABLE _sync_clients ( - [SQLite JSON Functions](https://sqlite.org/json1.html) - [RFC 4122 - UUID](https://tools.ietf.org/html/rfc4122) - [RFC 8785 - JSON Canonicalization](https://tools.ietf.org/html/rfc8785) -- [Dotmim.Sync Documentation](https://dotmimsync.readthedocs.io/) \ No newline at end of file +- [Dotmim.Sync Documentation](https://dotmimsync.readthedocs.io/) diff --git a/docs/specs/website-spec.md b/docs/specs/website-spec.md index f3f27e2a..a3a89b06 100644 --- a/docs/specs/website-spec.md +++ b/docs/specs/website-spec.md @@ -3,3 +3,12 @@ - No duplicate pages - leverage the readmes for content - Explain what each element of DataProvider does: LQL, DataProvider, Migrations, etc + +## DocFX Example Decoding [WEB-DOCFX-EXAMPLES] + +Decode DocFX code examples exactly once with the `entities` HTML decoder. +Nested escaped quotes and apostrophes remain entity text after one pass; +ordinary quotes, brackets, apostrophes and ampersands decode normally. +`Website/scripts/test-api-docs.py` runs the real generator against isolated +DocFX fixtures and checks the generated code blocks. Both `make test` and CI +run this regression without changing the real documentation output. diff --git a/opencode.json b/opencode.json index 0c8379c8..a2113bd0 100644 --- a/opencode.json +++ b/opencode.json @@ -1,5 +1,5 @@ { - "_agent_pmo": "74cf183", + "_agent_pmo": "795a9c2", "$schema": "https://opencode.ai/config.json", "instructions": ["CLAUDE.md"] } diff --git a/package-lock.json b/package-lock.json index 09d11905..626c152f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "devDependencies": { - "js-yaml": "^4.1.1" + "js-yaml": "^4.3.2" } }, "node_modules/argparse": { @@ -16,10 +16,20 @@ "license": "Python-2.0" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 53d87a21..e98ffe12 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "devDependencies": { - "js-yaml": "^4.1.1" + "js-yaml": "^4.3.2" } } diff --git a/shipwright.json b/shipwright.json new file mode 100644 index 00000000..1d12df70 --- /dev/null +++ b/shipwright.json @@ -0,0 +1,48 @@ +{ + "manifestVersion": 1, + "product": { + "id": "dataprovider-lql", + "displayName": "DataProvider LQL Language Support", + "version": "0.0.0-dev" + }, + "supplyChain": { + "provenance": true, + "sbom": true, + "signedChecksums": true, + "signerWorkflow": "Nimblesite/DataProvider/.github/workflows/release.yml@refs/tags/v*" + }, + "components": [ + { + "id": "lql-lsp", + "kind": "lsp", + "language": "rust", + "binaryName": "lql-lsp", + "expectedVersion": "0.0.0-dev", + "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"], + "sources": ["user-setting", "bundled"], + "required": true, + "githubRelease": { + "signature": true, + "provenance": true, + "sbom": true, + "signerWorkflow": "Nimblesite/DataProvider/.github/workflows/release.yml@refs/tags/v*" + } + }, + { + "id": "dataprovider-migrate", + "kind": "cli", + "language": "dotnet", + "binaryName": "DataProviderMigrate", + "expectedVersion": "0.0.0-dev", + "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"], + "sources": ["bundled"], + "required": false, + "githubRelease": { + "signature": true, + "provenance": true, + "sbom": true, + "signerWorkflow": "Nimblesite/DataProvider/.github/workflows/release.yml@refs/tags/v*" + } + } + ] +} diff --git a/tools/clinical-local.sh b/tools/clinical-local.sh new file mode 100644 index 00000000..60f48b6a --- /dev/null +++ b/tools/clinical-local.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Implements [CI-CLINICAL-LOCAL]: build and test the ClinicalCoding reference app +# (https://github.com/Nimblesite/ClinicalCoding) against this checkout instead of +# the published NuGet packages and dotnet tools. +# +# Usage: make clinical [CLINICAL_DIR=../ClinicalCoding] [CLINICAL_TARGETS="lint test build"] +# +# 1. Packs every DataProvider package + tool into artifacts/local-feed with a +# unique 0.0.0-local. version (unique so the NuGet cache never +# serves a stale build). +# 2. Points ClinicalCoding at that feed for the run only: a Directory.Build.rsp +# overrides DataProviderVersion and adds the feed as a restore source, and the +# local tool manifest is bumped to the same version. Both are restored on exit +# so the ClinicalCoding working tree is left untouched. +# 3. Runs the requested ClinicalCoding make targets. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +clinical_dir=$(cd "${CLINICAL_DIR:-$root/../ClinicalCoding}" && pwd) +targets=${CLINICAL_TARGETS:-lint test build} +feed="$root/artifacts/local-feed" +version="0.0.0-local.$(date -u +%Y%m%d%H%M%S)" +manifest="$clinical_dir/.config/dotnet-tools.json" +rsp="$clinical_dir/Directory.Build.rsp" + +if [ -e "$rsp" ]; then + echo "FAIL: $rsp already exists; refusing to overwrite it" >&2 + exit 1 +fi + +echo "==> Packing DataProvider $version into $feed" +rm -rf "$feed" +dotnet pack "$root/DataProvider.sln" --configuration Release -p:Version="$version" --output "$feed" + +cp "$manifest" "$manifest.local-backup" +restore_clinical() { + mv "$manifest.local-backup" "$manifest" + rm -f "$rsp" +} +trap restore_clinical EXIT + +printf -- '-p:DataProviderVersion=%s\n-p:RestoreAdditionalProjectSources=%s\n' \ + "$version" "$feed" >"$rsp" + +cd "$clinical_dir" +for tool in dataprovidermigrate dataprovider lql; do + dotnet tool update --local "$tool" --version "$version" --add-source "$feed" --allow-downgrade +done + +echo "==> Running ClinicalCoding targets against local DataProvider: $targets" +read -r -a target_list <<<"$targets" +make "${target_list[@]}" diff --git a/tools/shipwright-version-stamp.py b/tools/shipwright-version-stamp.py new file mode 100644 index 00000000..a4151cf1 --- /dev/null +++ b/tools/shipwright-version-stamp.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Shipwright build-time version stamper. [SWR-VERSION-BUILD-STAMPING] + +Rewrites every version carrier in the working tree from a release tag, using +structured parsers only (no regex on structured formats). Source stays at the +0.0.0-dev placeholder; the tag-triggered release stamps the runner working tree +and NEVER commits the result. + +Carriers stamped: + - Directory.Build.props (XML, ElementTree) + - Lql/lql-lsp-rust/Cargo.toml [workspace.package].version (TOML, table-aware walk) + - Lql/LqlExtension/package.json version (JSON) + - package.json version (JSON, only if present) + - shipwright.json product.version + every expectedVersion (JSON) + - Lql/LqlExtension/shipwright.json product.version + every expectedVersion (JSON) + +Usage: + shipwright-version-stamp.py --tag v1.2.3 --root . --dry-run # list carriers, change nothing + shipwright-version-stamp.py --tag v1.2.3 --root . # stamp the runner working tree +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +import xml.etree.ElementTree as ET +from pathlib import Path + + +def parse_version(tag: str) -> str: + """Strip a single leading 'v' from a release tag. [SWR-VERSION-MATCHING]""" + return tag[1:] if tag.startswith("v") else tag + + +def stamp_json(path: Path, version: str, dry_run: bool) -> list[str]: + data = json.loads(path.read_text(encoding="utf-8")) + changed: list[str] = [] + if isinstance(data.get("version"), str): + data["version"] = version + changed.append(f"{path}: version -> {version}") + product = data.get("product") + if isinstance(product, dict) and isinstance(product.get("version"), str): + product["version"] = version + changed.append(f"{path}: product.version -> {version}") + for component in data.get("components", []) or []: + if isinstance(component, dict) and isinstance(component.get("expectedVersion"), str): + component["expectedVersion"] = version + changed.append(f"{path}: components[{component.get('id')}].expectedVersion -> {version}") + if changed and not dry_run: + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return changed + + +def stamp_msbuild(path: Path, version: str, dry_run: bool) -> list[str]: + tree = ET.parse(path) + changed: list[str] = [] + for version_el in tree.getroot().iter("Version"): + version_el.text = version + changed.append(f"{path}: -> {version}") + if changed and not dry_run: + tree.write(path, encoding="utf-8", xml_declaration=False) + return changed + + +def stamp_cargo_workspace(path: Path, version: str, dry_run: bool) -> list[str]: + """Rewrite [workspace.package].version with a table-aware line walk. + + tomllib (structured read) validates the file and confirms the key exists; + the write is a single targeted line replacement guarded by the active table, + never a regex over the document. + """ + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + if "version" not in parsed.get("workspace", {}).get("package", {}): + return [] + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + out: list[str] = [] + active_table: str | None = None + changed: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + active_table = stripped[1:-1].strip() + out.append(line) + continue + key = stripped.split("=", 1)[0].strip() if "=" in stripped else "" + if active_table == "workspace.package" and key == "version" and not changed: + eol = "\r\n" if line.endswith("\r\n") else "\n" + out.append(f'version = "{version}"{eol}') + changed.append(f"{path}: [workspace.package].version -> {version}") + continue + out.append(line) + if changed and not dry_run: + path.write_text("".join(out), encoding="utf-8") + return changed + + +def main() -> int: + ap = argparse.ArgumentParser(description="Shipwright version stamper") + ap.add_argument("--tag", required=True, help="Release tag, e.g. v1.2.3") + ap.add_argument("--root", default=".", help="Repo root to stamp") + ap.add_argument("--dry-run", action="store_true", help="List carriers; change nothing") + args = ap.parse_args() + + version = parse_version(args.tag) + root = Path(args.root).resolve() + + carriers: list[tuple[Path, str]] = [ + (root / "Directory.Build.props", "msbuild"), + (root / "Lql" / "lql-lsp-rust" / "Cargo.toml", "cargo"), + (root / "Lql" / "LqlExtension" / "package.json", "json"), + (root / "package.json", "json"), + (root / "shipwright.json", "json"), + (root / "Lql" / "LqlExtension" / "shipwright.json", "json"), + ] + + handlers = {"msbuild": stamp_msbuild, "cargo": stamp_cargo_workspace, "json": stamp_json} + all_changes: list[str] = [] + for path, kind in carriers: + if not path.exists(): + continue + all_changes.extend(handlers[kind](path, version, args.dry_run)) + + mode = "DRY-RUN (no files changed)" if args.dry_run else "STAMPED" + print(f"shipwright-version-stamp {mode}: tag={args.tag} version={version} root={root}") + for change in all_changes: + print(f" {change}") + if not all_changes: + print(" ERROR: no version carriers found — refusing to release silently.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test-version-stamp.py b/tools/test-version-stamp.py new file mode 100644 index 00000000..3cc12a8e --- /dev/null +++ b/tools/test-version-stamp.py @@ -0,0 +1,63 @@ +"""Exercise the release CLI against real carriers. [SWR-VERSION-BUILD-STAMPING]""" +from pathlib import Path +import json +import shutil +import subprocess +import sys +import tempfile +import tomllib +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parent.parent +CARRIERS = ( + "Directory.Build.props", "Lql/lql-lsp-rust/Cargo.toml", + "Lql/LqlExtension/package.json", "package.json", + "shipwright.json", "Lql/LqlExtension/shipwright.json", +) + + +def assert_versions(root: Path, version: str) -> None: + assert ET.parse(root / CARRIERS[0]).findtext(".//Version") == version + cargo = tomllib.loads((root / CARRIERS[1]).read_text()) + assert cargo["workspace"]["package"]["version"] == version + for name in CARRIERS[2:]: + data = json.loads((root / name).read_text()) + if "product" in data: + assert data["product"]["version"] == version + assert all( + c["expectedVersion"] == version + for c in data["components"] if "expectedVersion" in c + ) + else: + assert data["version"] == version + + +def run_stamp(root: Path, version: str, dry_run: bool = False) -> None: + subprocess.run( + [sys.executable, str(ROOT / "tools/shipwright-version-stamp.py"), + "--root", str(root), "--tag", f"v{version}", + *(["--dry-run"] if dry_run else [])], + check=True, timeout=10, stdout=subprocess.DEVNULL, + ) + + +def test_release_carriers() -> None: + original = {name: (ROOT / name).read_bytes() for name in CARRIERS} + with tempfile.TemporaryDirectory(prefix="version-stamp-ci-") as directory: + root = Path(directory) + for name in CARRIERS: + target = root / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / name, target) + run_stamp(root=root, version="1.2.3", dry_run=True) + assert {name: (root / name).read_bytes() for name in CARRIERS} == original + package = json.loads((root / "package.json").read_text()) + (root / "package.json").write_text(json.dumps(package | {"version": "0.0.0-dev"})) + for version in ("1.2.3", "1.2.4-beta.5"): + run_stamp(root=root, version=version) + assert_versions(root=root, version=version) + assert {name: (ROOT / name).read_bytes() for name in CARRIERS} == original + + +if __name__ == "__main__": + test_release_carriers() diff --git a/tools/validate-manifest.py b/tools/validate-manifest.py new file mode 100644 index 00000000..3d5e080e --- /dev/null +++ b/tools/validate-manifest.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Vendored Shipwright manifest validator. [SWR-GATE-CI] [SWR-VERSION-MANIFEST] + +Structural check of shipwright.json: required keys, canonical platform ids, valid +component kinds and sources. Exits non-zero with a precise message on any violation. +Stands in for `@nimblesite/shipwright-validate-manifest` where npm is not wired. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +CANONICAL_PLATFORMS = frozenset( + {"darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64", "win32-x64", "win32-arm64", "all"} +) +VALID_KINDS = frozenset({"lsp", "mcp", "cli", "sidecar", "helper", "config"}) +VALID_SOURCES = frozenset({"user-setting", "env", "bundled", "github-release"}) +COMPONENT_KEYS = ("id", "kind", "language", "binaryName", "expectedVersion", "platforms", "sources", "required") + + +def validate(path: Path) -> list[str]: + errors: list[str] = [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"{path}: not readable/parseable JSON: {exc}"] + + if data.get("manifestVersion") != 1: + errors.append(f"{path}: manifestVersion must be 1") + product = data.get("product") + if not isinstance(product, dict) or not all(k in product for k in ("id", "displayName", "version")): + errors.append(f"{path}: product must have id, displayName, version") + + components = data.get("components") + if not isinstance(components, list) or not components: + return errors + [f"{path}: components must be a non-empty array"] + + for index, component in enumerate(components): + where = f"{path}: components[{index}]" + if not isinstance(component, dict): + errors.append(f"{where}: must be an object") + continue + for key in COMPONENT_KEYS: + if key not in component: + errors.append(f"{where}: missing required key '{key}'") + for platform in component.get("platforms", []) or []: + if platform not in CANONICAL_PLATFORMS: + errors.append(f"{where}: non-canonical platform '{platform}'") + if component.get("kind") not in VALID_KINDS: + errors.append(f"{where}: invalid kind '{component.get('kind')}'") + for source in component.get("sources", []) or []: + if source not in VALID_SOURCES: + errors.append(f"{where}: invalid source '{source}'") + return errors + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: validate-manifest.py ", file=sys.stderr) + return 2 + errors = validate(Path(sys.argv[1])) + if errors: + for error in errors: + print(f"::error::{error}", file=sys.stderr) + return 1 + print(f"{sys.argv[1]}: valid Shipwright manifest") + return 0 + + +if __name__ == "__main__": + sys.exit(main())